diff --git a/.github/workflows/LINT_CHECK.yml b/.github/workflows/LINT_CHECK.yml index 6455cef..c20ab92 100644 --- a/.github/workflows/LINT_CHECK.yml +++ b/.github/workflows/LINT_CHECK.yml @@ -40,3 +40,7 @@ jobs: - uses: ./.github/actions/setup - name: Check types run: pnpm typecheck + - name: Check attendance behavior + run: pnpm --filter @koin/attendance test + - name: Check attendance POSIX runtime + run: pnpm --filter @koin/attendance test:runtime diff --git a/apps/attendance/README.md b/apps/attendance/README.md index 8e728c2..0cc3d73 100644 --- a/apps/attendance/README.md +++ b/apps/attendance/README.md @@ -1,33 +1,72 @@ # 비공개 출근부 스냅샷·자동화 모니터 -`bcsd-attendance`는 Pi가 게시한 출입 현황의 관측 시점 스냅샷을 읽는 Worker입니다. 출입 원장을 저장하거나 회원·태그를 수정하지 않습니다. 오류 수신 요약과 Rocky 헬스는 별도 Worker service binding으로 조회합니다. +Pi의 Python 리더·LAN 서버가 학번을 Internal 회원과 대조하고 SQLite에 입퇴실을 저장합니다. Worker는 별도 KV 스냅샷을 제공하는 읽기 전용 원격 화면입니다. Worker VPC binding, Basic 비밀번호, UID 수동 등록은 사용하지 않습니다. + +```mermaid +flowchart LR + Card["학생증 HCE"] --> Reader["Pi reader.py"] + Reader --> LAN["loopback LAN API · SQLite"] + LAN --> Publisher["운영자 PC 또는 전용 게시기"] + Publisher --> KV["별도 VIEW_STATE KV"] + KV --> Viewer["Access · 조직 Gateway로 보호한 원격 화면"] +``` -## 인증과 읽기 전용 경계 +## 출입 원장과 회원 식별 + +- 출입 판정은 Pi의 SQLite에서만 수행합니다. 원격 KV의 지연된 읽기로 입퇴실을 판정하지 않습니다. +- 태그는 입실·퇴실을 번갈아 기록합니다. 중복·오래된 재전송은 새 출입으로 처리하지 않습니다. 최대 6시간을 넘긴 열린 세션은 다음 태그에서 미퇴실 세션으로 닫으며 유효 체류 시간에 합산하지 않습니다. +- 날짜·이번 주 경계는 KST입니다. 오늘·이번 주 요약은 양수인 유효 체류 시간이 있는 회원의 중앙값입니다. +- 학번 조회 404는 LAN의 익명 방문자 정책을 따릅니다. 원장에 학번이나 카드 UID를 저장하거나 UID를 학번으로 추측해 연결하지 않습니다. +- 현재 입실자는 이름·체류 시간만 4열×2행으로 표시하고 넘치는 인원은 페이지를 넘깁니다. 최근 기록·사이드바·수동 카드 등록 패널은 제공하지 않습니다. +- 16:9 TV 화면의 보이는 글씨는 24px 이상으로 표시합니다. 보조 설명을 줄이고 체류 표는 이름·오늘·이번 주 3열만 사용합니다. 공간이 부족하다고 글씨나 전체 화면 배율을 축소하지 않습니다. +- 자동 테마는 병천면의 일출·일몰을 브라우저에서 계산합니다. 외부 위치·폰트 서비스를 호출하지 않습니다. +- LAN은 저장 이벤트로 즉시 갱신하고 KOIN 모니터와 15초 간격으로 교대합니다. 유효 태그는 출근부로 복귀시키고 입실·퇴실 인사 모달을 표시합니다. +- 실물 학생증의 비금융 학번 읽기 규격이 없으면 지원으로 간주하지 않습니다. 보호 키·금융 앱을 탐색하거나 UID로 사람을 임의 등록하지 않습니다. + +## 원격 조회·접근 정책 + +- 해당 Worker의 모든 URL을 Cloudflare Access로 보호하고 조직 Gateway를 필수로 요구합니다. 일반 소비자 WARP나 클라이언트가 보낸 인증 헤더는 허용 근거가 아닙니다. +- Worker는 플랫폼의 `ctx.access.aud`를 실제 `CF_ACCESS_AUD`와 비교합니다. 인증되지 않은 별도 호스트·서비스 호출은 데이터를 읽지 못합니다. +- 회원 자격은 실제 Internal 학번·비밀번호 로그인과 매 요청의 `/v1/members/attendance-session` 검증으로 확인합니다. 이메일 일치, 회원 조회 성공, 별도 Keycloak 계정만으로 로그인 자격을 추정하지 않습니다. +- Internal의 15분 access token은 `Secure; HttpOnly; SameSite=Strict`의 `__Host-attendance-session` 쿠키에만 보관하며 JSON·브라우저 저장소에 전달하지 않습니다. 계정 비활성화·탈퇴·비밀번호 변경·만료는 다음 검증에서 차단하고 화면의 비공개 상태를 지웁니다. +- Worker는 고정된 `https://bcsd-attendance-auth.koreatech.in`의 로그인·세션 두 경로만 호출합니다. 전용 Cloudflare 서비스 토큰과 터널의 Access JWT 검증을 사용하며 일반 Internal API·관리자 경로는 공개하지 않습니다. +- 원격 화면은 기본 180초 간격 스냅샷이며 KV의 지역별 반영 지연이 추가됩니다. 체류 시간·지표는 실제 관측 시각에 고정하고 스냅샷임을 표시합니다. LAN의 즉시 갱신과 다릅니다. +- 관측 후 5분 초과, 필수 데이터 누락·손상, 인증·연결 실패 시 과거 수치를 현재처럼 연장하지 않고 사용 불가로 처리합니다. +- `VIEW_STATE`의 `clubroom:view:v1`만 조회합니다. 기존 `ATTENDANCE` KV 데이터는 보존하며 자동으로 병합·이관·삭제하지 않습니다. +- 서버 응답·관측 시각, 검증된 Wi-Fi PNG, 설정된 실제 Jam PNG만 게시합니다. 카메라·토큰·원본 태그 큐·원장 파일은 게시하지 않습니다. + +| Worker 경로 | 용도 | +| ------------------------------------------------------------ | ----------------------------------------- | +| `GET /`, `HEAD /` | 실제 LAN 템플릿 기반 원격 화면 | +| `POST /api/login`, `GET /api/me`, `POST /api/logout` | Internal 로그인·세션 확인·자기 쿠키 종료 | +| `GET /api/view-status`, `/health` | 스냅샷 가용성·관측 시각 | +| `GET /api/state` | 출근부 조회 | +| `GET /api/monitor`, `/api/koin-health`, `/api/worker-health` | 실제 서버·서비스 관측 응답 | +| `GET /api/spotify`, `/api/jam` | 관측 당시 재생 정보·실제 Jam 설정 여부 | +| `GET /api/wifi-qr/2.4`, `/api/wifi-qr/5`, `/api/jam-qr` | 스냅샷에 존재하는 검증된 PNG | +| `GET /api/automation` | Pi 스냅샷과 독립적인 오류 수신·Rocky 상태 | + +Worker의 태그·등록·관리자·카메라 쓰기 경로는 제공하지 않습니다. 로그인·로그아웃은 조회 세션만 변경하며 원격 화면에서 출입 원장을 변경할 수 없습니다. + +## Worker 빌드·배포 -- 플랫폼이 검증한 `ctx.access.aud`가 운영 `CF_ACCESS_AUD`와 같아야 화면·데이터에 접근할 수 있습니다. 클라이언트가 보낸 JWT 헤더·쿠키·Basic·기기 Bearer를 인증 근거로 해석하지 않습니다. -- `CF_ACCESS_AUD` 미설정은 503, 플랫폼 Access 컨텍스트 누락·다른 audience는 403입니다. `keep_vars: true`로 운영에서 관리하는 audience를 보존합니다. -- 허용 목록의 GET과 `/`의 HEAD만 받습니다. 쿼리·해시·관리자·카메라·로그인·쓰기 경로를 중계하지 않습니다. `/api/taps`와 `/api/members`는 제공하지 않습니다. -- 기존 `DEVICE_TOKEN`·`DASHBOARD_PASSWORD`를 자동 회전·삭제하지 않지만 원격 뷰어의 인증 우회 경로로 사용하지 않습니다. 신규 공개 경로나 Access 권한을 만들지 않습니다. +```sh +pnpm --filter @koin/attendance build +pnpm --filter @koin/attendance typecheck +pnpm --filter @koin/attendance test +pnpm --filter @koin/attendance test:runtime +pnpm deploy:attendance +``` -## Pi 관측 스냅샷 +Worker 검사는 Node.js 24에서 실행합니다. `test:runtime`은 POSIX 파일 권한을 사용하는 Pi·LAN 코드 때문에 Linux와 Python 3.11 이상이 필요합니다. Windows에서는 Worker 검사와 게시기 검사를 실행하고 리더·LAN 검사는 격리된 Pi/Linux에서 실행합니다. 런타임 검사는 실제 GPIO·회원 API·운영 원장을 사용하지 않습니다. -`VIEW_STATE`는 기존 조회 전용 KV `b519a98db46d4b088baee97a256bf0f0`의 `clubroom:view:v1`을 읽습니다. 원본 출입 원장 KV를 읽거나 쓰거나 치환하지 않습니다. 게시 작업은 별도 Pi 수집·게시 프로세스의 책임입니다. +빌드는 설치된 `tsx`로 LAN 렌더러를 실행해 `.wrangler/viewer/dashboard.html`을 생성합니다. Worker는 이 파일을 Text 모듈로 가져오며 별도 Static Assets 인증 우회 경로를 만들지 않습니다. -스냅샷은 `{version:1, observedAt, publishedAt, payloads, assets}`이며 최대 2 MiB입니다. 관측 시각보다 게시 시각이 빠르거나 미래인 자료, 5분 초과 자료, 누락·잘못된 자료는 503입니다. 실제 LAN origin으로 우회하거나 빈 값으로 바꾸지 않습니다. +`wrangler.jsonc`에는 실제 별도 KV ID와 service binding을 설정합니다. 운영의 `CF_ACCESS_AUD`는 별도 환경 변수로 관리하고 `keep_vars: true`로 보존합니다. 조직 Gateway·회원 자격 정책이 적용되기 전에는 `workers_dev`를 활성화하지 않습니다. 미리보기 URL은 비활성 상태를 유지하며 원장 KV를 삭제하거나 덮어쓰지 않습니다. -| 경로 | 응답 | -| -------------------------------------------------------- | -------------------------------------------- | -| `/` | 단일 읽기 전용 화면, HEAD 지원 | -| `/api/state` | 관측 시점의 출입 현황 | -| `/api/monitor`, `/api/koin-health`, `/api/worker-health` | Pi가 게시한 관측 시점 상태 | -| `/api/spotify`, `/api/jam` | 관측 시점 재생·참여 정보 | -| `/api/wifi-qr/2.4`, `/api/wifi-qr/5`, `/api/jam-qr` | 허용된 PNG, 개별 최대 256 KiB | -| `/api/view-status` | 스냅샷 가용성·관측/게시 시각·나이 | -| `/health` | 스냅샷 모드·관측/게시 시각·나이 | -| `/api/events` | 최대 60초 SSE, 10초마다 스냅샷 상태 확인 | -| `/api/automation` | 독립적인 오류 수신 요약·Rocky 읽기 전용 헬스 | +`INTERNAL_AUTH_CLIENT_ID`, `INTERNAL_AUTH_CLIENT_SECRET`은 인증 중계 앱 한 곳만 허용하는 전용 서비스 토큰으로 설정합니다. 기존 기기·lookup·monitor·관리자 토큰이나 Internal의 JWT 서명 키를 재사용하지 않습니다. 자격증명·비밀번호·원본 인증 응답을 Git이나 로그에 기록하지 않습니다. 인증 중계가 없거나 검증에 실패하면 데이터를 제공하지 않습니다. -스냅샷 응답은 게시된 상태 코드를 보존하며 `X-BCSD-Snapshot-*` 헤더로 관측 시각을 구분합니다. 모든 응답은 private/no-store, CSP, nosniff, 프레임 차단을 적용합니다. +로컬 `wrangler dev`에서는 실제 플랫폼 인증이 없으면 실패합니다. 인증 개발 설정은 로컬 프로세스에만 적용하고 운영에서 클라이언트 헤더로 인증을 흉내 내지 않습니다. ## 오류 수신·Rocky 읽기 전용 모니터 @@ -41,30 +80,29 @@ - 연결 실패·오프라인·숨김·기한 초과는 미확인으로 표시하고 마지막 관측은 과거 기록으로 구분합니다. Pi 스냅샷 표시가 중단돼도 독립 모니터는 표시합니다. - 오류 전문·프롬프트·소스·로그·원본 provider ID·lease·자격 증명은 반환하지 않습니다. 상세 호스트 설정은 [게이트웨이 운영 계약](../../scripts/autofix/README.md#rocky-읽기-전용-헬스-활성화)을 따릅니다. -## 화면과 배포 +## 스냅샷 게시기 -Pi 조회 화면·관측 시각·Wi-Fi/Spotify/모니터 화면을 유지하며 외부 UI 라이브러리나 Static Assets를 사용하지 않습니다. `src/dashboard.html`은 Worker Text 모듈입니다. 벽걸이 화면은 패널별 페이지를 사용하고 작은 화면에서는 재배치합니다. 회원 이름 등 데이터는 textContent로만 표시합니다. +`snapshot/publish.py`는 고정된 Pi5의 허용된 GET 경로만 수집합니다. 기본 실행은 I/O 없는 계획 출력입니다. 실제 account·namespace, 인증 출처, 영구 private checkpoint 디렉터리를 지정하고 `--once` 또는 `--loop`로 게시합니다. -웹·오류 자동화의 정상 main 승격 후 이 Worker를 별도 배포합니다. `VIEW_STATE` ID와 외부 관리 Access audience, 기존 secret을 유지합니다. secret 설정도 배포 작업입니다. 운영 승인 없이 원장 KV·도메인·Access 정책을 바꾸거나 별도 공개 테스트 Worker를 만들지 않습니다. +- 게시 간격은 최소 180초, 일일 시도 예산은 600회입니다. 실패한 쓰기도 예산에 포함하며 공유 계정 한도 때문에 무과금을 보장하지 않습니다. +- 전용 기기 토큰의 KV Storage Write는 계정 범위입니다. 다른 KV에 대한 쓰기 위험을 검토한 뒤 사용합니다. +- 운영자 PC의 기존 Wrangler 세션은 명시적 `--operator-wrangler-auth-file`로만 사용합니다. 광범위한 OAuth 세션을 Pi로 복사하지 않습니다. 만료 시 인증된 `wrangler kv namespace list`로 정상 갱신하며 `whoami`만으로 갱신됐다고 간주하지 않습니다. +- 관리자 WARP 경로가 SSH만 허용하면 먼저 고정 호스트 키를 검증하는 `ssh -N -L 127.0.0.1:8080:127.0.0.1:8080 bcsd-pi`를 연결하고 게시기에 `--ssh-forward`를 지정합니다. 연결 대상만 localhost로 바꾸며 원래 Host와 허용 경로를 유지합니다. +- 게시기·인증·사설 조회 경로가 중단되면 원격 화면도 신선도 제한 후 사용할 수 없습니다. cloudflared 설치 자체는 게시기나 Internal 송신 인증을 대신하지 않습니다. -```sh -pnpm --dir apps/attendance exec wrangler types --include-runtime false -pnpm --dir apps/attendance typecheck -pnpm --dir apps/attendance test -pnpm --dir apps/attendance exec wrangler deploy --dry-run -``` +## LAN 서버 + +`lan/server.py`는 IPv4 `0.0.0.0:8080`에 바인딩하지만 읽기 출발지는 loopback·`192.168.1.0/24`로 제한합니다. 이 LAN 읽기에는 로그인이 없고 실제 이름·Wi-Fi 비밀번호를 볼 수 있습니다. 인터넷에 직접 게시하지 않습니다. -로컬에서도 플랫폼 Access 컨텍스트가 없으면 403입니다. 클라이언트 헤더를 인증 우회로 추가하지 않습니다. API 회귀는 격리된 플랫폼 컨텍스트·KV fixture로 검증합니다. +태그 POST는 loopback 출발지, 올바른 Host·Origin, 별도 `DEVICE_TOKEN`을 모두 요구합니다. 서비스는 root 소유 `0600` 파일의 토큰을 systemd credential로 전달받습니다. `/etc/attendance-lan/config`의 `INTERNAL_LOOKUP_URL`은 고정된 HTTPS `api.internal.bcsdlab.com/v1/service/attendance/member-lookup`이며, lookup·device·monitor 자격증명은 목적별로 분리합니다. HTTPS 전달은 실제 Internal 호스트의 SNI·인증서를 검증하고 TLS 검증을 끄지 않습니다. -## LAN 리더 배선·설치 +`lan/attendance-lan.service`와 `pi/attendance-reader.service`를 활성화하면 부팅 시 실행됩니다. 실제 사설 회원 조회 경로·토큰을 준비한 뒤 시작합니다. SQLite와 원본 큐는 private 디렉터리·파일 권한을 유지합니다. -Raspberry Pi 3B 기준입니다. `READER`로 리더 종류를 고릅니다. -리더의 `ATTENDANCE_URL`은 승인된 LAN 수집 API 주소입니다. 이 읽기 전용 원격 Worker 주소로 태그를 전송하지 않습니다. +Wi-Fi QR은 실제 비밀번호로 생성한 작은 PNG를 로컬에 설치하고 `WIFI_QR_24_FILE`, `WIFI_QR_5_FILE`로 지정합니다. 화면을 볼 수 있는 사람은 QR에서 비밀번호를 알 수 있으므로 표시 범위를 제한합니다. Jam은 실제 휴대폰 초대 링크로 만든 root 소유 PNG를 `JAM_QR_FILE`로 지정하며 미설정이면 숨깁니다. 곡·기기 URL로 Jam 초대를 만들어 대체하지 않습니다. -- `rc522`: GPIO(SPI)에 직접 연결한 RC522 모듈. 카드 UID를 16진수 대문자(예: `04A1B2C3`)로 보내고, 결과를 LED 두 개로 알립니다. -- `hid`(기본값): 카드 UID를 키 입력 + Enter로 보내는 USB HID 리더. 장치를 독점해 UID가 콘솔에 입력되지 않으며, 장치가 여러 개면 `READER_DEVICE`로 경로를 지정합니다. +## 라즈베리파이 리더 -같은 카드라도 리더 종류에 따라 UID 문자열이 다를 수 있으므로, 리더를 바꾸면 회원을 다시 등록합니다. +리더는 Android 학생증 HCE에서 읽은 학번·태그 시각을 loopback LAN 서버에만 전송합니다. 서버가 조회한 회원 정보로 입퇴실을 처리하며, 클라우드 UID API·HID 수동 등록은 사용하지 않습니다. 칩 초기화·RF 복구 후에는 카드가 제거된 간격을 확인한 뒤 새 태그를 받으므로 계속 올려 둔 카드를 재시작만으로 새 출입으로 처리하지 않습니다. ### RC522 배선 @@ -84,42 +122,10 @@ Raspberry Pi 3B 기준입니다. `READER`로 리더 종류를 고릅니다. | 빨강 LED | GPIO → 330 Ω → 애노드, 캐소드 → GND | 18 (GPIO24) | | LED 공통 GND | GND | 20 | -| 결과 | LED | -| --------------------------- | ------------- | -| 입실 | 초록 1초 | -| 퇴실 | 초록 2번 깜빡 | -| 10초 안 중복(무시) | 초록 짧게 1번 | -| 미등록 카드 | 빨강 1초 | -| 오프라인·서버 오류(큐 보관) | 빨강 2번 깜빡 | -| 거절(잘못된 UID 등) | 빨강 3번 깜빡 | - -시작할 때 초록·빨강이 차례로 한 번 켜집니다. 카드는 읽은 뒤 HALT시키므로 떼었다 다시 대야 다시 읽힙니다. - ### 설치 -```sh -sudo raspi-config nonint do_spi 0 # rc522: 부팅 시 SPI 활성화 -sudo dtparam spi=on # rc522: 재부팅 없이 현재 부팅에도 적용 -sudo apt install python3-spidev python3-rpi-lgpio # Raspberry Pi OS Trixie. hid는 python3-evdev -sudo install -D -o root -g root -m 755 pi/reader.py /opt/attendance/reader.py -sudo install -o root -g root -m 644 pi/attendance-reader.service /etc/systemd/system/ -sudo systemctl daemon-reload -``` - -RC522는 `/dev/spidev0.0`과 위 배선을 사용합니다. SPI 장치 파일이 있다는 것만으로 모듈 연결이 확인되지는 않습니다. 시작 시 `RC522 응답 없음`이면 3.3V 전원·GND·CE0·SCLK·MOSI·MISO·RST 배선을 확인합니다. USB HID는 `/dev/input/by-id/*-event-kbd`에 실제 리더가 있어야 합니다. +`reader.py`와 `hce.py`를 함께 `/opt/attendance`에 설치하고 SPI·GPIO 의존성을 준비합니다. APT를 사용할 때는 먼저 해당 Raspberry Pi OS에 맞는 한국 미러를 설정합니다. `/dev/spidev0.0` 존재만으로 카드 통신을 보장하지 않습니다. -실제 LAN 수집 API 주소와 전용 기기 토큰을 받은 뒤에만 런타임 설정을 작성하고 서비스를 시작합니다. 진단 중이거나 모듈·카드 인식이 확인되지 않았다면 전송 루프를 켜지 않습니다. - -```sh -sudo touch /etc/attendance-reader.env -sudo chown root:root /etc/attendance-reader.env -sudo chmod 600 /etc/attendance-reader.env -sudoedit /etc/attendance-reader.env -# ATTENDANCE_URL=<실제 LAN 수집 API 주소> -# DEVICE_TOKEN= -# READER=rc522 (USB HID는 hid; 필요하면 READER_DEVICE도 지정) -sudo systemctl enable --now attendance-reader.service -``` +리더 환경 파일은 root 소유 `0600`으로 관리하고 `ATTENDANCE_URL=http://127.0.0.1:8080`, LAN 서버와 일치하는 `DEVICE_TOKEN`을 지정합니다. 원본 큐 기본 경로는 `/var/lib/attendance/queue.jsonl`이며 `QUEUE_FILE`로 private 경로를 지정할 수 있습니다. 학번·토큰·큐 원문을 Git·로그·공유 문서에 넣지 않습니다. -- 오프라인이면 태그를 `/var/lib/attendance/queue.jsonl`에 보관하고 30초마다 순서대로 재전송합니다. 태그 시각(`at`)은 파이 시계 기준이므로 NTP 동기화가 필요합니다. -- 인증 오류(401/403)·408·429·5xx는 큐에 남겨 재시도하고, 그 외 4xx(잘못된 UID 등)는 버립니다. +서비스는 제한된 private 상태 디렉터리를 사용합니다. 전송 실패는 원래 태그 시각으로 순서대로 재전송하므로 NTP 동기화가 필요합니다. 인증·연결 실패를 성공이나 임의 회원 등록으로 바꾸지 않습니다. diff --git a/apps/attendance/lan/attendance-lan.service b/apps/attendance/lan/attendance-lan.service new file mode 100644 index 0000000..719acd3 --- /dev/null +++ b/apps/attendance/lan/attendance-lan.service @@ -0,0 +1,30 @@ +[Unit] +Description=BCSD LAN attendance dashboard and Internal member lookup +After=network.target + +[Service] +Type=simple +DynamicUser=yes +EnvironmentFile=/etc/attendance-lan/config +LoadCredential=lookup:/etc/attendance-lan/lookup-token +LoadCredential=device:/etc/attendance-lan/device-token +Environment=INTERNAL_LOOKUP_TOKEN_FILE=%d/lookup +Environment=DEVICE_TOKEN_FILE=%d/device +Environment=ATTENDANCE_HTML=/opt/attendance/lan/dashboard.html +Environment=ATTENDANCE_DATA_DIR=/var/lib/attendance-lan +ExecStart=/usr/bin/python3 /opt/attendance/lan/server.py +StateDirectory=attendance-lan +StateDirectoryMode=0700 +UMask=0077 +Restart=on-failure +RestartSec=5 +NoNewPrivileges=yes +CapabilityBoundingSet= +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +RestrictAddressFamilies=AF_INET AF_UNIX + +[Install] +WantedBy=multi-user.target diff --git a/apps/attendance/lan/render-dashboard.mjs b/apps/attendance/lan/render-dashboard.mjs new file mode 100644 index 0000000..d00ec1e --- /dev/null +++ b/apps/attendance/lan/render-dashboard.mjs @@ -0,0 +1,1105 @@ +#!/usr/bin/env node +// Render src/dashboard.ts with the tsx loader; only the generated HTML runs on the Pi. +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const [modulePath, outputPath] = process.argv.slice(2); +if (!modulePath || !outputPath) + throw new Error('Usage: node --import tsx lan/render-dashboard.mjs '); +const { DASHBOARD_HTML } = await import(pathToFileURL(resolve(modulePath)).href); +if (typeof DASHBOARD_HTML !== 'string') throw new Error('Expected the real DASHBOARD_HTML export'); +const startMarker = 'async function checkSession() {'; +const endMarker = "document.getElementById('login-form').addEventListener"; +const start = DASHBOARD_HTML.indexOf(startMarker); +const end = DASHBOARD_HTML.indexOf(endMarker, start); +if (start < 0 || end < start || DASHBOARD_HTML.indexOf(startMarker, start + 1) !== -1) { + throw new Error('Dashboard authentication structure changed; refusing to generate a partial page'); +} +const pollMarker = 'setInterval(refresh, POLL_MS);'; +if ( + DASHBOARD_HTML.indexOf(pollMarker) < 0 || + DASHBOARD_HTML.indexOf(pollMarker) !== DASHBOARD_HTML.lastIndexOf(pollMarker) || + !DASHBOARD_HTML.includes('if (refreshing || !authActive) return;') +) { + throw new Error('Dashboard refresh structure changed; refusing generate partial page'); +} +const manualRefreshMarker = "document.getElementById('refresh').addEventListener('click', refresh);"; +const headerActionsMarker = '
'; +const fitMarker = 'function fitWall() {'; +for (const marker of [manualRefreshMarker, fitMarker, headerActionsMarker, '']) { + if (!DASHBOARD_HTML.includes(marker) || DASHBOARD_HTML.indexOf(marker) !== DASHBOARD_HTML.lastIndexOf(marker)) { + throw new Error('Dashboard view structure changed; refusing to generate a partial page'); + } +} +const spotifyStyles = ` +.app-header > .brand, .app-header > .header-actions { flex-shrink:0; } +.app-header > .header-actions { min-width:0; } +.app-header .viewer-name { display:none; } +.spotify-now { flex:1 1 320px; min-width:0; max-width:480px; height:68px; padding:4px 10px; display:flex; align-items:center; gap:10px; border:1px solid var(--line); border-radius:16px; background:var(--surface-low); overflow:hidden; } +.spotify-copy { display:grid; grid-template-rows:29px 29px; align-content:center; flex:1; min-width:0; } +.spotify-cover { flex:0 0 56px; width:56px; height:56px; object-fit:cover; border-radius:7px; } +.spotify-title, .spotify-artists { display:block; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.spotify-title { color:var(--text); font-size:24px; font-weight:650; line-height:29px; } +.spotify-artists { color:var(--muted); font-size:24px; line-height:29px; } +@media (min-width:1000px) and (min-aspect-ratio:3/2) { +.app-header > .brand, .app-header > .header-actions { min-height:68px; } + .app-header h1 { margin-top:2px; font-size:clamp(26px,3vh,32px); line-height:1.2; } + .app-header #clock { font-size:clamp(26px,3vh,32px); line-height:1.2; } +.app-header #date { font-size:24px; line-height:1.2; } +} +@media (max-width:999px) { + .app-header { flex-wrap:wrap; } + .spotify-now { order:3; flex-basis:100%; max-width:none; } +} +@media (max-width:700px) { + .app-header > .brand, .app-header > .header-actions { flex-shrink:1; } +} +`; +const spotifyMarkup = ` +
+ +
+ 재생 상태를 확인하고 있습니다 + Spotify Connect +
+
+`; +const jamStyles = ` +.wifi-codes.has-jam { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); align-items:center; gap:6px; } +.wifi-codes.has-jam .wifi-code img { width:clamp(96px,13vh,128px); height:auto; aspect-ratio:1; max-width:100%; } +.wifi-codes.has-jam .wifi-code figcaption { margin-top:2px; font-size:24px; line-height:1.2; white-space:nowrap; } +`; +const monitorStyles = ` +.monitor-view { --monitor-cpu:var(--primary); --monitor-ram:var(--amber); display:grid; align-content:start; gap:16px; min-width:0; } +.monitor-heading, .monitor-title, .monitor-section-heading { display:flex; align-items:center; justify-content:space-between; gap:12px 20px; } +.monitor-title { justify-content:flex-start; flex-wrap:wrap; } +.monitor-heading h2 { margin:0; font-size:clamp(28px,3.4vh,40px); line-height:1.2; } +.monitor-description { margin:0; color:var(--muted); font-size:24px; line-height:1.2; } +.monitor-status { display:inline-flex; align-items:center; gap:10px; flex-shrink:0; margin:0; padding:8px 16px; border-radius:999px; background:var(--amber-container); color:var(--amber); font-size:clamp(24px,2.2vh,28px); line-height:1.2; font-weight:750; } +.monitor-status::before { content:""; width:13px; height:13px; flex-shrink:0; border-radius:50%; background:currentColor; } +.monitor-status[data-state="up"] { color:var(--on-green-container); background:var(--green-container); } +.monitor-status[data-state="down"] { color:var(--error); background:var(--error-container); } +.monitor-paths { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; } +.monitor-path { display:flex; align-items:center; justify-content:space-between; gap:10px; margin:0; padding:8px 16px; border:1px solid var(--line); border-radius:999px; color:var(--amber); background:var(--amber-container); line-height:1.2; } +.monitor-path strong { color:var(--text); font-size:clamp(24px,2.5vh,30px); font-weight:750; } +.monitor-path span { display:inline-flex; align-items:center; gap:8px; font-size:clamp(24px,2.2vh,28px); font-weight:750; white-space:nowrap; } +.monitor-path span::before { content:""; width:10px; height:10px; flex-shrink:0; border-radius:50%; background:currentColor; } +.monitor-path[data-health="up"] { color:var(--on-green-container); background:var(--green-container); } +.monitor-path[data-health="down"] { color:var(--error); background:var(--error-container); } +.worker-health { display:flex; align-items:center; justify-content:space-between; gap:8px 12px; min-width:0; margin:0; padding:8px 16px; border:1px solid var(--line); border-radius:24px; background:var(--surface-low); } +.worker-health > strong { color:var(--text); font-size:clamp(24px,2.3vh,28px); line-height:1.2; white-space:nowrap; } +.worker-status { display:inline-flex; align-items:center; gap:6px; color:var(--amber); font-size:24px; font-weight:750; line-height:1.2; white-space:nowrap; } +.worker-status[data-health="up"] { color:var(--on-green-container); } +.worker-status[data-health="down"] { color:var(--error); } +@media (max-width:1100px) { .worker-health { flex-wrap:wrap; } .worker-health > strong { flex-basis:100%; } } +.koin-services { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; } +.koin-service { display:grid; gap:8px; padding:clamp(10px,1.8vh,22px) clamp(16px,2vw,30px); border:1px solid var(--line); border-left:6px solid var(--amber); border-radius:20px; background:var(--surface); } +.koin-service[data-health="up"] { border-left-color:var(--green); } +.koin-service[data-health="down"] { border-left-color:var(--error); } +.koin-service-heading { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:4px 12px; } +.koin-service-name { margin:0; font-size:clamp(26px,3.1vh,38px); font-weight:750; line-height:1.2; } +.koin-service-detail { display:flex; align-items:baseline; justify-content:space-between; flex-wrap:wrap; gap:2px 14px; color:var(--muted); font-size:24px; line-height:1.2; } +.koin-service-reason, .koin-service-time { margin:0; } +.monitor-health { display:inline-flex; align-items:center; gap:8px; width:fit-content; flex-shrink:0; padding:4px 10px; border-radius:999px; color:var(--amber); background:var(--amber-container); font-size:clamp(24px,2.1vh,26px); font-weight:750; line-height:1.2; white-space:nowrap; } +.monitor-health::before { content:""; width:9px; height:9px; flex-shrink:0; border-radius:50%; background:currentColor; } +.monitor-health[data-health="up"] { color:var(--on-green-container); background:var(--green-container); } +.monitor-health[data-health="down"] { color:var(--error); background:var(--error-container); } +.monitor-section-heading h3 { margin:0; font-size:clamp(24px,2.5vh,30px); line-height:1.2; } +.monitor-timestamp { margin:0; color:var(--muted); font-size:24px; line-height:1.2; text-align:right; } +.monitor-timestamp, .monitor-node-time, .koin-service-time { font-variant-numeric:tabular-nums; } +.monitor-cards { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:18px; min-width:0; min-height:0; } +.monitor-node.panel { display:grid; grid-template-rows:auto auto minmax(0,1fr) auto; align-content:start; gap:clamp(8px,1.1vh,16px); padding:clamp(12px,1.8vh,24px); border-radius:22px; min-width:0; } +.monitor-node-heading { min-width:0; } +.monitor-node-title { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:6px 10px; } +.monitor-node h4 { margin:0; font-size:clamp(24px,3vh,36px); line-height:1.2; font-weight:750; overflow-wrap:anywhere; } +.monitor-node-time { margin:0; padding-top:8px; border-top:1px solid var(--line); color:var(--muted); font-size:24px; line-height:1.2; } +.monitor-value-note { margin:5px 0 0; color:var(--amber); font-size:24px; line-height:1.2; } +.monitor-metrics { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; } +.monitor-metric { min-width:0; } +.monitor-metric-label { display:flex; align-items:center; gap:9px; color:var(--monitor-cpu); font-size:clamp(24px,2.2vh,26px); line-height:1.2; font-weight:750; } +.monitor-metric-label::before { content:""; width:27px; border-top:5px solid currentColor; } +.monitor-metric[data-metric="ram"] .monitor-metric-label { color:var(--monitor-ram); } +.monitor-metric[data-metric="ram"] .monitor-metric-label::before { border-top-style:dashed; } +.monitor-value { display:block; margin-top:3px; font-size:clamp(44px,5.4vh,72px); font-weight:750; letter-spacing:-.055em; line-height:1.05; font-variant-numeric:tabular-nums; } +.monitor-plot { display:flex; flex-direction:column; min-height:0; } +.monitor-graph { display:block; flex:1; width:100%; height:clamp(100px,15vh,180px); min-height:100px; overflow:hidden; } +.monitor-graph-grid { fill:none; stroke:var(--outline); stroke-width:1; vector-effect:non-scaling-stroke; } +.monitor-graph-line { fill:none; stroke:var(--monitor-cpu); stroke-width:5; stroke-linecap:round; stroke-linejoin:round; vector-effect:non-scaling-stroke; } +.monitor-graph-line[data-series="ram"] { stroke:var(--monitor-ram); stroke-dasharray:10 7; } +.monitor-graph-dot { fill:var(--monitor-cpu); } +.monitor-graph-dot[data-series="ram"] { fill:var(--monitor-ram); } +.monitor-graph-axis { display:flex; align-items:center; justify-content:space-between; gap:8px; color:var(--muted); font-size:24px; line-height:1.2; } +.monitor-waiting { grid-column:1 / -1; margin:0; padding:16px; color:var(--muted); font-size:clamp(24px,2.5vh,30px); border:2px dashed var(--outline); border-radius:20px; } +@media (max-width:999px) { + .monitor-heading { align-items:start; flex-direction:column; } + .monitor-cards { grid-template-columns:minmax(0,1fr); } + .monitor-node.panel { grid-template-rows:auto auto auto auto; padding:22px; } + .monitor-graph { flex:none; height:170px; } + .monitor-value { font-size:52px; } + .monitor-node h4 { font-size:30px; } +.monitor-health { font-size:24px; } +} +@media (max-width:650px) { + .monitor-paths { grid-template-columns:minmax(0,1fr); } + .koin-services { grid-template-columns:minmax(0,1fr); } + .monitor-section-heading { align-items:start; flex-direction:column; gap:6px; } + .monitor-timestamp { text-align:left; } + .koin-service-name { font-size:26px; } +.monitor-description { font-size:24px; } + .monitor-title { gap:8px; } +.monitor-status { font-size:24px; } +} +@media (min-width:1000px) and (min-aspect-ratio:3/2) { + .monitor-view { flex:1; min-height:0; gap:clamp(8px,1vh,14px); grid-template-rows:auto auto auto auto minmax(0,1fr); } + .monitor-title { flex-wrap:nowrap; gap:16px; } + .monitor-heading { gap:12px; } + .monitor-cards { gap:clamp(12px,1.2vw,24px); } + .monitor-node.panel { min-height:0; } + .koin-service-name, .monitor-node h4 { font-size:clamp(32px,3.1vh,38px); } + .monitor-value { font-size:clamp(58px,5.4vh,72px); } +.monitor-health { font-size:clamp(24px,2.1vh,26px); } + .monitor-node.panel { padding:clamp(10px,1.4vh,22px); gap:clamp(6px,.9vh,14px); } + .monitor-node-time { padding-top:4px; } + .monitor-graph { height:0; min-height:0; flex:1 1 0; } + .monitor-graph-axis, .monitor-node-time { flex-shrink:0; } +} +@media (min-width:1000px) and (min-aspect-ratio:3/2) and (max-height:800px) { + .monitor-view { grid-template-columns:minmax(0,1fr) minmax(0,1fr); grid-template-rows:auto auto auto minmax(0,1fr); gap:6px 12px; } + .monitor-heading, .monitor-section-heading, .monitor-cards { grid-column:1 / -1; } + .monitor-paths { grid-template-columns:minmax(0,1fr); gap:4px; align-content:start; } + .koin-services { gap:8px; } + .monitor-node.panel { padding:8px 10px; gap:5px; } + .monitor-node-title { gap:4px 8px; } + .monitor-node-heading { min-height:0; } + .monitor-metrics { gap:8px; } +.monitor-value-note { margin-top:2px; font-size:24px; } +.monitor-node-time { line-height:1.2; } +.monitor-heading h2 { font-size:28px; } +.monitor-description { font-size:24px; } +.monitor-status { padding:6px 12px; font-size:24px; } +.monitor-path { padding:6px 12px; } +.monitor-path strong, .monitor-path span { font-size:24px; } +.koin-service { padding:8px 12px; gap:4px; } +.koin-service-name { font-size:26px; } +.koin-service-detail, .monitor-timestamp, .monitor-node-time { font-size:24px; } +.monitor-section-heading h3 { font-size:24px; } +.monitor-health { font-size:24px; } +.monitor-node h4 { font-size:30px; } +.monitor-metric-label { font-size:24px; } +.monitor-value { font-size:48px; } +.monitor-graph-axis { font-size:24px; } +} +`; +const monitorMarkup = ` + +`; +const greetingStyles = ` + .greeting-dialog { + width:min(560px,calc(100vw - 32px)); max-width:calc(100vw - 32px); + max-height:calc(100vh - 32px); max-height:calc(100dvh - 32px); + margin:auto; padding:clamp(24px,4vw,40px); overflow:auto; + border:1px solid var(--line); border-radius:28px; + background:var(--surface-high); color:var(--text); + box-shadow:0 24px 80px #0004; text-align:center; + } + .greeting-dialog.is-entering { animation:greeting-enter 220ms cubic-bezier(.2,.8,.2,1) both; } + .greeting-dialog.is-closing { animation:greeting-exit 160ms ease-in both; } + @keyframes greeting-enter { + from { opacity:0; transform:translateY(12px); } + to { opacity:1; transform:translateY(0); } + } + @keyframes greeting-exit { + from { opacity:1; transform:translateY(0); } + to { opacity:0; transform:translateY(8px); } + } + @media (prefers-reduced-motion:reduce) { + .greeting-dialog.is-entering, .greeting-dialog.is-closing { animation:none; } + } + .greeting-dialog::backdrop { background:#0006; } + .greeting-brand { + display:inline-block; margin:0 0 20px; padding:6px 14px; border-radius:999px; + background:var(--primary-container); color:var(--on-primary-container); + font-size:24px; font-weight:750; letter-spacing:.12em; + } + .greeting-message { + margin:0; font-size:clamp(24px,3vw,36px); font-weight:700; + line-height:1.5; letter-spacing:-.04em; overflow-wrap:anywhere; + } + .greeting-close { min-width:96px; } +`; +const greetingMarkup = ` + + +

+ +
+`; +// LAN mode has no fabricated session endpoint. Keep the real rendering and +// refresh/auth guards; queue invalidations arriving during an in-flight fetch. +const bootstrap = `var lanEvents = null; + var lanLastEventAt = 0; + var lanPageHidden = false; + var lanRefreshPending = false; + var lanGreetingTimer = null; + var lanGreetingAnimationTimer = null; + var lanGreetingDialog = document.getElementById('greeting-dialog'); + var lanGreetingMessage = document.getElementById('greeting-message'); + var lanView = 'attendance'; + var lanRotationTimer = null; + var lanMonitorInterval = null; + var lanMonitorAgeInterval = null; + var lanRecoveryInterval = null; + var lanMonitorRequest = null; + var lanMonitorData = null; + var lanMonitorFailed = false; + var lanMonitorSucceededAt = null; + var lanWorkerData = null; + var lanWorkerFailed = false; + var lanWorkerRequest = null; + var lanWorkerRequestTimer = null; + var lanWorkerInterval = null; + var lanSpotifyInterval = null; + var lanSpotifyRequest = null; + var lanSpotifyRequestTimer = null; + var lanSpotifySignature = null; + var lanSpotifyCover = document.getElementById('spotify-cover'); + var lanSpotifyCoverKey = null; + var LAN_SPOTIFY_POLL_MS = 2500; + var lanSpotifyNode = document.getElementById('spotify-now'); + var lanSpotifyTitle = document.getElementById('spotify-title'); + var lanSpotifyArtists = document.getElementById('spotify-artists'); + var lanJamInterval = null; + var lanJamRequest = null; + var lanJamRequestTimer = null; + var lanJamImage = null; + var lanJamImageTimer = null; + var lanJamFigure = null; + var lanJamAvailable = false; + var lanJamCodes = document.querySelector('#wifi-section .wifi-codes'); + var LAN_JAM_POLL_MS = 60000; + var LAN_WORKER_POLL_MS = 60000; + var LAN_WORKER_STALE_MS = 150000; + var lanMonitorCards = []; +var lanKoinData = null; +var lanKoinFailed = false; +var lanKoinRows = []; +var lanMonitorHistory = Object.create(null); +var LAN_HISTORY_LIMIT = 60; +var LAN_HISTORY_MS = 15 * 60 * 1000; +var LAN_GRAPH_GAP_MS = 22500; + var LAN_VIEW_MS = 15000; + var LAN_STALE_MS = 45000; + var lanTimestampFormat = new Intl.DateTimeFormat('ko-KR', { + timeZone: 'Asia/Seoul', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false + }); + + function lanIsVisible() { + return authActive && !lanPageHidden && !document.hidden; + } + function showLanView(view) { +if (view === 'monitor' && lanGreetingDialog.open) return; + lanView = view; + var attendance = document.getElementById('main'); + var monitor = document.getElementById('monitor-view'); + var previous = view === 'attendance' ? monitor : attendance; + var moveFocus = previous.contains(document.activeElement); + attendance.hidden = view !== 'attendance'; + monitor.hidden = view !== 'monitor'; + if (view === 'attendance') scheduleFit(); + else updateLanMonitorStatus(); + if (moveFocus) (view === 'attendance' ? attendance : monitor).focus({ preventScroll: true }); + } + function resetLanRotation() { + clearTimeout(lanRotationTimer); + lanRotationTimer = null; + if (!lanIsVisible()) return; + lanRotationTimer = setTimeout(function () { + lanRotationTimer = null; + if (!lanIsVisible()) return; + if (!lanGreetingDialog.open) showLanView(lanView === 'attendance' ? 'monitor' : 'attendance'); + resetLanRotation(); + }, LAN_VIEW_MS); + } + function attendLanScreen() { + showLanView('attendance'); + resetLanRotation(); + } + function clearLanGreeting() { + clearTimeout(lanGreetingTimer); + clearTimeout(lanGreetingAnimationTimer); + lanGreetingTimer = null; + lanGreetingAnimationTimer = null; + lanGreetingDialog.classList.remove('is-entering', 'is-closing'); + lanGreetingMessage.textContent = ''; + } + function closeLanGreeting(immediate) { + clearTimeout(lanGreetingTimer); + clearTimeout(lanGreetingAnimationTimer); + lanGreetingTimer = null; + lanGreetingAnimationTimer = null; + if (!lanGreetingDialog.open) { clearLanGreeting(); return; } + if (immediate === true || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + lanGreetingDialog.close(); + clearLanGreeting(); + return; + } + lanGreetingDialog.classList.remove('is-entering'); + lanGreetingDialog.classList.add('is-closing'); + lanGreetingAnimationTimer = setTimeout(function () { + lanGreetingAnimationTimer = null; + if (lanGreetingDialog.open) lanGreetingDialog.close(); + }, 160); + } + function showLanGreeting(data) { + if (!lanIsVisible() || !data || (data.name !== null && typeof data.name !== 'string') + || (data.action !== 'check-in' && data.action !== 'check-out')) return; + attendLanScreen(); + clearTimeout(lanGreetingTimer); + clearTimeout(lanGreetingAnimationTimer); + lanGreetingAnimationTimer = null; + lanGreetingMessage.textContent = data.action === 'check-out' + ? (data.name === null ? '' : data.name + '님, ') + '안녕히 가세요' + : data.name === null + ? '안녕하세요 BCSDLab에 처음이신것 같아요' + : data.name + '님 안녕하세요'; + if (!lanGreetingDialog.open) lanGreetingDialog.showModal(); + lanGreetingDialog.classList.remove('is-entering', 'is-closing'); + if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + void lanGreetingDialog.offsetWidth; + lanGreetingDialog.classList.add('is-entering'); + lanGreetingAnimationTimer = setTimeout(function () { + lanGreetingAnimationTimer = null; + lanGreetingDialog.classList.remove('is-entering'); + }, 240); + } + lanGreetingTimer = setTimeout(closeLanGreeting, 5000); + } + document.getElementById('greeting-close').addEventListener('click', function () { closeLanGreeting(); }); + lanGreetingDialog.addEventListener('close', function () { + if (!lanGreetingDialog.open) clearLanGreeting(); + }); + + function validLanTimestamp(value) { + return Number.isSafeInteger(value) && value >= 0 && value <= 8640000000000000; + } + function validLanMonitor(data) { + if (!data || !validLanTimestamp(data.observedAt) + || !Array.isArray(data.nodes) || data.nodes.length !== 3) return false; + var ids = new Set(); + return data.nodes.every(function (node) { + if (!node || ['prod', 'stage', 'db'].indexOf(node.id) < 0 || ids.has(node.id) + || typeof node.label !== 'string' || !node.label.trim() + || ['up', 'down', 'unknown'].indexOf(node.health) < 0 + || (node.collectedAt !== null && !validLanTimestamp(node.collectedAt)) + || (node.cpuPercent !== null && (!Number.isFinite(node.cpuPercent) || node.cpuPercent < 0 || node.cpuPercent > 100)) + || (node.memoryUsedBytes !== null && (!Number.isSafeInteger(node.memoryUsedBytes) || node.memoryUsedBytes < 0)) + || (node.memoryTotalBytes !== null && (!Number.isSafeInteger(node.memoryTotalBytes) || node.memoryTotalBytes <= 0)) + || (node.memoryUsedBytes !== null && node.memoryTotalBytes !== null && node.memoryUsedBytes > node.memoryTotalBytes)) return false; + ids.add(node.id); + return true; + }); + } + function validLanKoin(data) { + if (!data || !validLanTimestamp(data.observedAt) + || !Array.isArray(data.services) || data.services.length !== 2) return false; + var labels = { 'prod-api': 'KOIN API 운영', 'stage-api': 'KOIN API 개발' }; + var ids = new Set(); + return data.services.every(function (service) { + if (!service || !Object.prototype.hasOwnProperty.call(labels, service.id) || ids.has(service.id) + || service.label !== labels[service.id] || !validLanTimestamp(service.checkedAt) + || ['up', 'down', 'unknown'].indexOf(service.status) < 0 + || ['ready', 'not-ready', 'http', 'tls', 'timeout', 'invalid-response', 'unavailable'].indexOf(service.reason) < 0 + || (service.httpStatus !== null && (!Number.isInteger(service.httpStatus) || service.httpStatus < 100 || service.httpStatus > 599)) + || (service.status === 'up' && (service.reason !== 'ready' || service.httpStatus !== 200)) + || (service.status === 'down' && service.reason !== 'not-ready' + && !(service.reason === 'http' && service.httpStatus >= 500)) + || (service.status === 'unknown' && service.reason === 'ready')) return false; + ids.add(service.id); + return true; + }); + } + function lanPublicUrl(value, host, prefix) { + if (typeof value !== 'string' || value.length > 512 || !value.startsWith('https://' + host + prefix)) return null; + try { + var url = new URL(value); + var id = url.pathname.slice(prefix.length); + if (url.protocol !== 'https:' || url.hostname !== host || url.username || url.password + || url.port || url.search || url.hash || !url.pathname.startsWith(prefix) + || !/^[a-zA-Z0-9]{1,128}$/.test(id)) return null; + return url.href; + } catch (_error) { return null; } + } + function validLanWorkers(data) { + if (!data || !validLanTimestamp(data.checkedAt) + || !Array.isArray(data.workers) || data.workers.length !== 1) return false; + var ids = new Set(); + return data.workers.every(function (worker) { + if (!worker || worker.id !== 'koin-web' || ids.has(worker.id) + || typeof worker.label !== 'string' || !worker.label.trim() || worker.label.length > 80 + || ['up', 'down', 'unknown'].indexOf(worker.status) < 0 + || ['http', 'restricted', 'redirect', 'tls', 'timeout', 'unavailable'].indexOf(worker.reason) < 0 + || (worker.httpStatus !== null && (!Number.isInteger(worker.httpStatus) + || worker.httpStatus < 100 || worker.httpStatus > 599))) return false; + ids.add(worker.id); + var code = worker.httpStatus; + if (code === null) return worker.status === 'unknown' + && ['tls', 'timeout', 'unavailable'].indexOf(worker.reason) >= 0; + if (code >= 200 && code < 300) return worker.status === 'up' && worker.reason === 'http'; + if (code >= 500) return worker.status === 'down' && worker.reason === 'http'; + if (worker.status !== 'unknown') return false; + return worker.reason === (code >= 300 && code < 400 ? 'redirect' + : code === 401 || code === 403 ? 'restricted' : 'http'); + }); + } + function lanTimestamp(value) { + return value === null ? '시각 없음' : lanTimestampFormat.format(value) + ' KST'; + } + function lanIsFresh(value, now) { + return value !== null && now - value <= LAN_STALE_MS && now - value >= -5000; + } + function lanPercent(value) { + return value === null ? '—' : value.toLocaleString('ko-KR', { maximumFractionDigits: 1 }) + '%'; + } + function lanMemoryPercent(node) { + return node.memoryUsedBytes === null || node.memoryTotalBytes === null + ? null : node.memoryUsedBytes / node.memoryTotalBytes * 100; + } + function lanBytes(value) { + if (value === null) return '—'; + var gib = value >= 10737418240; + return (value / (gib ? 1073741824 : 1048576)).toLocaleString('ko-KR', { maximumFractionDigits: gib ? 1 : 0 }) + + (gib ? ' GiB' : ' MiB'); + } + function lanHistoryFor(id) { + if (!lanMonitorHistory[id]) lanMonitorHistory[id] = { samples: [], lastAt: null, breakNext: false }; + return lanMonitorHistory[id]; + } + function lanBreakHistory() { + Object.keys(lanMonitorHistory).forEach(function (id) { lanMonitorHistory[id].breakNext = true; }); + } + function lanPruneHistory(history, now) { + while (history.samples.length && (history.samples.length > LAN_HISTORY_LIMIT + || history.samples[0].at < now - LAN_HISTORY_MS)) history.samples.shift(); + } + function rememberLanMonitor() { + var now = Date.now(); + lanMonitorData.nodes.forEach(function (node) { + var history = lanHistoryFor(node.id); + lanPruneHistory(history, now); + if (!lanIsFresh(lanMonitorData.observedAt, now) || !lanIsFresh(node.collectedAt, now)) { + history.breakNext = true; + return; + } + var cpu = node.health === 'up' ? node.cpuPercent : null; + var ram = node.health === 'up' ? lanMemoryPercent(node) : null; + if (history.lastAt !== null && node.collectedAt <= history.lastAt) { + if (cpu === null || ram === null) history.breakNext = true; + return; + } + if (history.samples.length === LAN_HISTORY_LIMIT) history.samples.shift(); + history.samples.push({ + at: node.collectedAt, cpu: cpu, ram: ram, + gapBefore: history.breakNext || (history.lastAt !== null && node.collectedAt - history.lastAt > LAN_GRAPH_GAP_MS) + }); + history.lastAt = node.collectedAt; + history.breakNext = false; + lanPruneHistory(history, now); + }); + } + function lanSvg(tag, attributes) { + var element = document.createElementNS('http://www.w3.org/2000/svg', tag); + Object.keys(attributes).forEach(function (name) { element.setAttribute(name, attributes[name]); }); + return element; + } + function lanDrawGraph(plot, history, now) { + if (plot.drawAt !== undefined && now >= plot.drawAt && now - plot.drawAt < LAN_VIEW_MS + && plot.drawLastAt === history.lastAt && plot.drawCount === history.samples.length) return; + plot.drawAt = now; + plot.drawLastAt = history.lastAt; + plot.drawCount = history.samples.length; + var svg = plot.graph; + svg.replaceChildren(); + svg.appendChild(lanSvg('path', { d: 'M8 12H592 M8 88H592 M8 164H592', class: 'monitor-graph-grid' })); + var firstAt = history.samples.length ? history.samples[0].at : now; + var lastAt = history.samples.length ? history.samples[history.samples.length - 1].at : now; + var span = Math.max(0, lastAt - firstAt); + plot.range.textContent = span >= 60000 ? Math.ceil(span / 60000) + '분 구간' + : span > 0 ? Math.ceil(span / 1000) + '초 구간' : '관측 시작'; + var counts = { cpu: 0, ram: 0 }; + ['cpu', 'ram'].forEach(function (key) { + var path = ''; + var connected = false; + history.samples.forEach(function (sample) { + var value = sample[key]; + if (value === null) { connected = false; return; } + var x = span > 0 ? 8 + Math.max(0, Math.min(1, (sample.at - firstAt) / span)) * 584 : 300; + var y = 164 - value / 100 * 152; + path += (connected && !sample.gapBefore ? 'L' : 'M') + x.toFixed(2) + ' ' + y.toFixed(2) + ' '; + svg.appendChild(lanSvg('circle', { + cx: x.toFixed(2), cy: y.toFixed(2), r: '4', class: 'monitor-graph-dot', 'data-series': key + })); + connected = true; + counts[key] += 1; + }); + if (path) svg.appendChild(lanSvg('path', { d: path, class: 'monitor-graph-line', 'data-series': key })); + }); + svg.setAttribute('aria-label', '실제 관측 구간, 최대 15분, 0에서 100퍼센트. CPU 실측 ' + counts.cpu + '개, RAM 실측 ' + counts.ram + '개. 빈 구간은 미관측.'); + } + function lanMetric(key, label) { + var block = el('div', 'monitor-metric'); + block.dataset.metric = key; + var value = el('strong', 'monitor-value', '—'); + block.append(el('span', 'monitor-metric-label', label), value); + return { block: block, value: value }; + } + function renderLanMonitor() { + var container = document.getElementById('monitor-cards'); + container.replaceChildren(); + lanMonitorCards = []; + ['prod', 'stage', 'db'].forEach(function (id) { + var node = lanMonitorData.nodes.find(function (entry) { return entry.id === id; }); + var card = el('article', 'panel monitor-node'); + card.dataset.node = id; + var heading = el('div', 'monitor-node-heading'); + var title = el('div', 'monitor-node-title'); + var health = el('span', 'monitor-health'); + title.append(el('h4', '', node.label), health); + var note = el('p', 'monitor-value-note'); + heading.append(title, note); + var metrics = el('div', 'monitor-metrics'); + var cpu = lanMetric('cpu', 'CPU'); + var ram = lanMetric('ram', 'RAM'); + metrics.append(cpu.block, ram.block); + var plotBlock = el('div', 'monitor-plot'); + var graph = lanSvg('svg', { viewBox: '0 0 600 180', preserveAspectRatio: 'none', class: 'monitor-graph', role: 'img' }); + var graphAxis = el('div', 'monitor-graph-axis'); + var graphRange = el('span', '', '관측 시작'); + graphAxis.append(graphRange, el('span', '', '최근 수집')); + plotBlock.append(graph, graphAxis); + var timestamp = el('p', 'monitor-node-time'); + card.append(heading, metrics, plotBlock, timestamp); + container.appendChild(card); + lanMonitorCards.push({ + node: node, health: health, note: note, timestamp: timestamp, cpu: cpu, ram: ram, + plot: { graph: graph, range: graphRange } + }); + }); + } + function renderLanKoin() { + var container = document.getElementById('koin-services'); + container.replaceChildren(); + lanKoinRows = []; + ['prod-api', 'stage-api'].forEach(function (id) { + var service = lanKoinData.services.find(function (entry) { return entry.id === id; }); + var row = el('div', 'koin-service'); + row.dataset.service = id; + var health = el('span', 'monitor-health'); + var detail = el('div', 'koin-service-detail'); + var reason = el('p', 'koin-service-reason'); + var timestamp = el('p', 'koin-service-time'); + detail.append(reason, timestamp); + var heading = el('div', 'koin-service-heading'); + heading.append(el('h3', 'koin-service-name', service.label), health); + row.append(heading, detail); + container.appendChild(row); + lanKoinRows.push({ row: row, service: service, health: health, reason: reason, timestamp: timestamp }); + }); + } + function updateLanPaths(now) { + var internal = document.getElementById('internal-path-status'); + var internalUp = !lanMonitorFailed && lanIsFresh(lanMonitorSucceededAt, now); + internal.dataset.health = internalUp ? 'up' : 'unknown'; + internal.querySelector('span').textContent = internalUp ? '연결 정상' : '연결·인증 확인'; + internal.title = '유효한 /api/monitor 응답 수신 기준 · 노드 상태와 별개' + + (lanMonitorSucceededAt === null ? '' : ' · 수신 ' + lanTimestamp(lanMonitorSucceededAt)); + var data = lanWorkerData; + var age = data ? now - data.checkedAt : Infinity; + var fresh = !lanWorkerFailed && data && age >= -5000 && age <= LAN_WORKER_STALE_MS; + var states = ['koin-web'].map(function (id) { + var worker = fresh ? data.workers.find(function (entry) { return entry.id === id; }) : null; + var state = worker ? worker.status : 'unknown'; + var node = document.getElementById('worker-web-status'); + node.dataset.health = state; + node.querySelector('span').textContent = (state === 'up' ? '정상' : state === 'down' ? '장애' : '확인 불가') + + (worker && worker.httpStatus !== null ? ' · ' + worker.httpStatus : ''); + return state; + }); + document.getElementById('worker-health').dataset.health = states.indexOf('down') >= 0 ? 'down' + : states.every(function (state) { return state === 'up'; }) ? 'up' : 'unknown'; + } + function updateLanMonitorStatus() { + var now = Date.now(); + updateLanPaths(now); + var states = []; + var reasons = { + ready: 'readiness UP', 'not-ready': 'readiness 준비 안 됨', http: 'HTTP 응답', + tls: 'TLS 확인 실패', timeout: '응답 시간 초과', 'invalid-response': '응답 형식 확인 불가', unavailable: '연결 확인 불가' + }; + lanKoinRows.forEach(function (entry) { + var service = entry.service; + var fresh = !lanKoinFailed && lanIsFresh(lanKoinData.observedAt, now) && lanIsFresh(service.checkedAt, now); + var state = fresh ? service.status : 'unknown'; + states.push(state); + entry.row.dataset.health = state; + entry.health.dataset.health = state; + entry.health.textContent = state === 'up' ? '준비됨' : state === 'down' ? '준비 안 됨' : '확인 불가'; + entry.reason.textContent = !fresh ? '최신 상태 확인 불가' + : reasons[service.reason] + (service.httpStatus === null ? '' : ' · HTTP ' + service.httpStatus); + entry.reason.hidden = fresh && service.reason === 'ready'; + entry.timestamp.textContent = '확인 ' + lanTimestamp(service.checkedAt); + }); + if (!lanKoinData) states.push('unknown'); + lanMonitorCards.forEach(function (entry) { + var node = entry.node; + var fresh = !lanMonitorFailed && lanIsFresh(lanMonitorData.observedAt, now) && lanIsFresh(node.collectedAt, now); + var snapshotFresh = !lanMonitorFailed && lanIsFresh(lanMonitorData.observedAt, now); + var cpu = fresh && node.health === 'up' ? node.cpuPercent : null; + var ram = fresh && node.health === 'up' ? lanMemoryPercent(node) : null; + var state = snapshotFresh && node.health === 'down' ? 'down' : !fresh ? 'unknown' + : node.health === 'up' && cpu !== null && ram !== null ? 'up' : 'unknown'; + states.push(state); + entry.health.dataset.health = state; + entry.health.textContent = state === 'up' ? '수집 정상' : state === 'down' ? '수집 실패' : '확인 불가'; + entry.timestamp.textContent = '수집 ' + lanTimestamp(node.collectedAt); + entry.note.textContent = !fresh ? '최신 수치 확인 불가' + : cpu === null || ram === null ? '일부 미수집' : ''; + entry.note.hidden = !entry.note.textContent; + entry.cpu.value.textContent = lanPercent(cpu); + entry.ram.value.textContent = lanPercent(ram); + entry.ram.value.title = ram === null ? '사용 / 전체 확인 불가' + : lanBytes(node.memoryUsedBytes) + ' / ' + lanBytes(node.memoryTotalBytes); + var history = lanHistoryFor(node.id); + if (!fresh) history.breakNext = true; + lanPruneHistory(history, now); + lanDrawGraph(entry.plot, history, now); + }); + if (!lanMonitorData) states.push('unknown'); + var state = states.indexOf('down') >= 0 ? 'down' : states.indexOf('unknown') >= 0 ? 'unknown' : 'up'; + var status = document.getElementById('monitor-status'); + status.dataset.state = state; + status.textContent = state === 'up' ? '관측 정상' + : state === 'down' ? '일부 장애 / 수집 실패' : '일부 상태 확인 불가'; + document.getElementById('monitor-timestamp').textContent = lanMonitorData + ? '서버 관측 ' + lanTimestamp(lanMonitorData.observedAt) : '서버 관측 대기'; + if (!lanKoinData) document.querySelector('#koin-services .monitor-waiting').textContent = lanKoinFailed + ? '상태 확인 불가' : '상태 확인 중'; + if (!lanMonitorData) document.querySelector('#monitor-cards .monitor-waiting').textContent = lanMonitorFailed + ? '서버 조회 실패 · 노드 관측 없음' : '서버 수집 데이터 대기 · 아직 노드 관측 없음'; + } + async function refreshLanMonitor() { + if (!lanIsVisible() || lanMonitorRequest) return; + var controller = new AbortController(); + lanMonitorRequest = controller; + var timeout = setTimeout(function () { controller.abort(); }, 8000); + async function collect(path, validate, accept, fail) { + try { + var response = await fetch(path, { cache: 'no-store', credentials: 'same-origin', signal: controller.signal }); + if (response.status !== 200) throw new Error('Snapshot unavailable'); + var data = await response.json(); + if (!validate(data)) throw new Error('Invalid snapshot'); + if (lanMonitorRequest !== controller || !lanIsVisible()) return; + accept(data); + } catch (_error) { + if (lanMonitorRequest !== controller || !lanIsVisible()) return; + fail(); + } + if (lanMonitorRequest === controller && lanIsVisible()) updateLanMonitorStatus(); + } + try { + await Promise.all([ + collect('/api/monitor', validLanMonitor, function (data) { + lanMonitorData = data; + lanMonitorFailed = false; + lanMonitorSucceededAt = Date.now(); + rememberLanMonitor(); + renderLanMonitor(); + }, function () { lanMonitorFailed = true; lanBreakHistory(); }), + collect('/api/koin-health', validLanKoin, function (data) { + lanKoinData = data; + lanKoinFailed = false; + renderLanKoin(); + }, function () { lanKoinFailed = true; }) + ]); + } finally { + clearTimeout(timeout); + if (lanMonitorRequest === controller) lanMonitorRequest = null; + } + } + async function refreshLanWorkers() { + if (!lanIsVisible() || lanWorkerRequest) return; + var controller = new AbortController(); + lanWorkerRequest = controller; + lanWorkerRequestTimer = setTimeout(function () { controller.abort(); }, 8000); + try { + var response = await fetch('/api/worker-health', { + method: 'GET', cache: 'no-store', credentials: 'omit', signal: controller.signal + }); + if (response.status !== 200) throw new Error('Worker status unavailable'); + var data = await response.json(); + if (!validLanWorkers(data)) throw new Error('Invalid Worker status'); + if (lanWorkerRequest !== controller || !lanIsVisible()) return; + lanWorkerData = data; + lanWorkerFailed = false; + } catch (_error) { + if (lanWorkerRequest !== controller || !lanIsVisible()) return; + lanWorkerData = null; + lanWorkerFailed = true; + } finally { + if (lanWorkerRequest === controller) { + clearTimeout(lanWorkerRequestTimer); + lanWorkerRequestTimer = null; + lanWorkerRequest = null; + if (lanIsVisible()) updateLanPaths(Date.now()); + } + } + } + function renderLanSpotifyCover(key, url) { + if (key === lanSpotifyCoverKey) return; + lanSpotifyCoverKey = key; + lanSpotifyCover.hidden = true; + lanSpotifyCover.onload = null; + lanSpotifyCover.onerror = null; + lanSpotifyCover.removeAttribute('src'); + if (!url) return; + // A new element isolates stale load/error events when tracks change rapidly. + var image = new Image(44, 44); + image.id = 'spotify-cover'; + image.className = 'spotify-cover'; + image.alt = ''; + image.referrerPolicy = 'no-referrer'; + image.decoding = 'async'; + image.hidden = true; + image.onload = function () { + if (lanSpotifyCover === image && lanSpotifyCoverKey === key) image.hidden = false; + }; + image.onerror = function () { + if (lanSpotifyCover !== image || lanSpotifyCoverKey !== key) return; + image.hidden = true; + image.removeAttribute('src'); + }; + lanSpotifyCover.replaceWith(image); + lanSpotifyCover = image; + image.src = url; + } + function renderLanSpotify(data) { + var labels = { + idle: '대기 중', loading: '불러오는 중', playing: '재생 중', + paused: '일시 정지', error: '연결 오류', unknown: '상태 확인 불가' + }; + var state = data && Object.prototype.hasOwnProperty.call(labels, data.state) ? data.state : 'unknown'; + var title = ''; + var artists = ''; + if (state === 'playing' || state === 'paused' || state === 'loading') { + title = typeof data.title === 'string' && data.title.trim() + ? data.title : (state === 'loading' ? '곡 정보를 불러오고 있습니다' : '제목 정보 없음'); + artists = Array.isArray(data.artists) + ? data.artists.filter(function (artist) { return typeof artist === 'string' && artist.trim(); }).join(', ') : ''; + if (!artists) artists = '아티스트 정보 없음'; + } else if (state === 'idle') { + title = '재생 중인 항목 없음'; + artists = ''; + } else if (state === 'error') { + title = '재생 정보를 가져올 수 없습니다'; + artists = ''; + } else { + title = '재생 상태를 확인할 수 없습니다'; + artists = ''; + } + var active = state === 'playing' || state === 'paused' || state === 'loading'; + var cover = active ? lanPublicUrl(data.coverUrl, 'i.scdn.co', '/image/') : null; + // Timestamps alone must not mutate the live region or repeat announcements. + var signature = JSON.stringify([state, title, artists, cover]); + if (signature === lanSpotifySignature) return; + lanSpotifySignature = signature; + lanSpotifyNode.dataset.state = state; + lanSpotifyNode.setAttribute('aria-label', labels[state]); + lanSpotifyTitle.textContent = title; + lanSpotifyArtists.textContent = artists; + renderLanSpotifyCover(active ? JSON.stringify([title, artists, cover]) : null, cover); + } + async function refreshLanSpotify() { + if (!lanIsVisible() || lanSpotifyRequest) return; + var controller = new AbortController(); + lanSpotifyRequest = controller; + lanSpotifyRequestTimer = setTimeout(function () { + if (lanSpotifyRequest === controller) controller.abort(); + }, 5000); + try { + var response = await fetch('/api/spotify', { + cache: 'no-store', credentials: 'same-origin', signal: controller.signal + }); + if (!response.ok) throw new Error('Spotify status unavailable'); + var data = await response.json(); + if (lanSpotifyRequest !== controller || !lanIsVisible()) return; + renderLanSpotify(data); + } catch (_error) { + if (lanSpotifyRequest !== controller || !lanIsVisible()) return; + renderLanSpotify({ state: 'error' }); + } finally { + if (lanSpotifyRequest === controller) { + clearTimeout(lanSpotifyRequestTimer); + lanSpotifyRequestTimer = null; + lanSpotifyRequest = null; + } + } + } + function clearLanJamImage() { + clearTimeout(lanJamImageTimer); + lanJamImageTimer = null; + if (lanJamImage) { + var image = lanJamImage; + lanJamImage = null; + image.onload = null; + image.onerror = null; + image.removeAttribute('src'); + } + if (lanJamFigure) lanJamFigure.remove(); + lanJamFigure = null; + lanJamCodes.classList.remove('has-jam'); + } + function renderLanJam(available) { + lanJamAvailable = available; + if (!available) { clearLanJamImage(); return; } + if (lanJamImage) return; + var figure = document.createElement('figure'); + figure.id = 'spotify-jam'; + figure.className = 'wifi-code'; + var image = new Image(); + image.id = 'spotify-jam-qr'; + image.alt = 'Spotify Jam 참여 QR 코드'; + image.decoding = 'async'; + var caption = document.createElement('figcaption'); + caption.textContent = 'Spotify Jam'; + figure.append(image, caption); + lanJamFigure = figure; + lanJamImage = image; + image.onload = function () { + if (lanJamImage !== image || !lanJamAvailable || !lanIsVisible()) return; + clearTimeout(lanJamImageTimer); + lanJamImageTimer = null; + lanJamCodes.appendChild(figure); + lanJamCodes.classList.add('has-jam'); + }; + image.onerror = function () { + if (lanJamImage !== image) return; + clearLanJamImage(); + }; + lanJamImageTimer = setTimeout(function () { + if (lanJamImage !== image) return; + clearLanJamImage(); + }, 8000); + image.src = '/api/jam-qr'; + } + async function refreshLanJam() { + if (!lanIsVisible() || lanJamRequest) return; + var controller = new AbortController(); + lanJamRequest = controller; + lanJamRequestTimer = setTimeout(function () { + if (lanJamRequest === controller) controller.abort(); + }, 5000); + try { + var response = await fetch('/api/jam', { + cache: 'no-store', credentials: 'same-origin', signal: controller.signal + }); + if (response.status !== 200) throw new Error('Jam status unavailable'); + var data = await response.json(); + if (!data || typeof data.available !== 'boolean') throw new Error('Invalid Jam status'); + if (lanJamRequest !== controller || !lanIsVisible()) return; + renderLanJam(data.available); + } catch (_error) { + if (lanJamRequest !== controller || !lanIsVisible()) return; + renderLanJam(false); + } finally { + if (lanJamRequest === controller) { + clearTimeout(lanJamRequestTimer); + lanJamRequestTimer = null; + lanJamRequest = null; + } + } + } + function refreshLanActiveView() { + refreshLanSpotify(); + refreshLanJam(); + if (lanView === 'monitor') refreshLanMonitor(); + else refresh(); + } + var lanOriginalRefresh = refresh; + refresh = async function () { + if (!lanIsVisible()) return; + if (refreshing) { lanRefreshPending = true; return; } + do { + lanRefreshPending = false; + await lanOriginalRefresh(); + } while (lanRefreshPending && lanIsVisible()); + }; + function stopLanEvents() { + if (lanEvents) { lanEvents.close(); lanEvents = null; } + } + function startLanEvents() { + if (!lanIsVisible() || !window.EventSource) return; + if (lanEvents && lanEvents.readyState !== EventSource.CLOSED) return; + stopLanEvents(); + try { + var source = new EventSource('/api/events'); + lanEvents = source; + lanLastEventAt = Date.now(); + source.addEventListener('state', function () { + if (lanEvents !== source) return; + lanLastEventAt = Date.now(); + refresh(); + }); + source.addEventListener('ping', function () { + if (lanEvents === source) lanLastEventAt = Date.now(); + }); + source.addEventListener('attention', function () { + if (lanEvents !== source || !lanIsVisible()) return; + lanLastEventAt = Date.now(); + attendLanScreen(); + }); + source.addEventListener('greeting', function (event) { + if (lanEvents !== source || !lanIsVisible()) return; + lanLastEventAt = Date.now(); + attendLanScreen(); + try { showLanGreeting(JSON.parse(event.data)); } + catch (_error) { /* Never retain or log event payloads. */ } + }); + } catch (_error) { stopLanEvents(); } + } + function recoverLanState() { + if (!lanIsVisible()) return; + var stalled = lanEvents && Date.now() - lanLastEventAt > POLL_MS; + if (!lanEvents || lanEvents.readyState !== EventSource.OPEN || stalled + || document.getElementById('sync-status').dataset.state === 'error') { + if (stalled) stopLanEvents(); + startLanEvents(); + refresh(); + } + } + function stopLanActivity() { + lanBreakHistory(); + clearInterval(lanJamInterval); + clearTimeout(lanJamRequestTimer); + lanJamInterval = null; + lanJamRequestTimer = null; + if (lanJamRequest) { + var jamRequest = lanJamRequest; + lanJamRequest = null; + jamRequest.abort(); + } + renderLanJam(false); + stopLanEvents(); + clearInterval(lanSpotifyInterval); + clearTimeout(lanSpotifyRequestTimer); + lanSpotifyInterval = null; + lanSpotifyRequestTimer = null; + if (lanSpotifyRequest) { + var spotifyRequest = lanSpotifyRequest; + lanSpotifyRequest = null; + spotifyRequest.abort(); + } + renderLanSpotify({ state: 'unknown' }); + clearTimeout(lanRotationTimer); + clearInterval(lanMonitorInterval); + clearInterval(lanMonitorAgeInterval); + clearInterval(lanRecoveryInterval); + clearInterval(lanWorkerInterval); + clearTimeout(lanWorkerRequestTimer); + lanWorkerInterval = null; + lanWorkerRequestTimer = null; + if (lanWorkerRequest) { + var workerRequest = lanWorkerRequest; + lanWorkerRequest = null; + workerRequest.abort(); + } + lanWorkerData = null; + lanWorkerFailed = true; + updateLanPaths(Date.now()); + lanRotationTimer = null; + lanMonitorInterval = null; + lanMonitorAgeInterval = null; + lanRecoveryInterval = null; + if (lanMonitorRequest) { + var request = lanMonitorRequest; + lanMonitorRequest = null; + request.abort(); + } + lanRefreshPending = false; + closeLanGreeting(true); + if (fitFrame) { cancelAnimationFrame(fitFrame); fitFrame = 0; } + } + function startLanActivity() { + if (!lanIsVisible() || lanMonitorInterval !== null) return; + attendLanScreen(); + startLanEvents(); + refresh(); + refreshLanMonitor(); + refreshLanWorkers(); + refreshLanSpotify(); + lanSpotifyInterval = setInterval(refreshLanSpotify, LAN_SPOTIFY_POLL_MS); + refreshLanJam(); + lanJamInterval = setInterval(refreshLanJam, LAN_JAM_POLL_MS); + lanWorkerInterval = setInterval(refreshLanWorkers, LAN_WORKER_POLL_MS); + lanMonitorInterval = setInterval(refreshLanMonitor, LAN_VIEW_MS); + lanMonitorAgeInterval = setInterval(updateLanMonitorStatus, 1000); + lanRecoveryInterval = setInterval(recoverLanState, POLL_MS); + } + document.addEventListener('visibilitychange', function () { + if (document.hidden) stopLanActivity(); + else startLanActivity(); + }); + window.addEventListener('pagehide', function () { + lanPageHidden = true; + stopLanActivity(); + }); + window.addEventListener('pageshow', function () { + lanPageHidden = false; + startLanActivity(); + }); + async function checkSession() { + authActive = true; + authRevision += 1; + authDeadline = Infinity; + authExpiresAt = Infinity; + authClockOffset = 0; + document.getElementById('login-panel').hidden = true; + document.getElementById('main').hidden = false; + document.getElementById('logout').hidden = true; + document.getElementById('refresh').hidden = false; + document.getElementById('viewer-name').textContent = '임시 LAN 운영 · 로그인 없음'; + document.getElementById('viewer-name').hidden = false; + loadWifiImages(true); + startLanActivity(); + return true; + } + `; +const html = (DASHBOARD_HTML.slice(0, start) + bootstrap + DASHBOARD_HTML.slice(end)) + .replace(pollMarker, '') + .replace(manualRefreshMarker, "document.getElementById('refresh').addEventListener('click', refreshLanActiveView);") + .replace( + fitMarker, + "function fitWall() {\n if (typeof lanView !== 'undefined' && (lanView !== 'attendance' || document.hidden || lanPageHidden)) return;", + ) + .replace(headerActionsMarker, spotifyMarkup + headerActionsMarker) + .replace('', '' + monitorMarkup) + .replace('', greetingStyles + monitorStyles + spotifyStyles + jamStyles + '') + .replace(' + `, + ); + replace( + '', + ` + .automation-monitor { flex-shrink:0; display:grid; gap:2px; margin:0 0 4px; padding:4px 10px; border:1px solid var(--line); border-radius:16px; background:var(--surface); } + .automation-row { display:flex; align-items:center; flex-wrap:wrap; gap:2px 10px; min-width:0; font-size:24px; line-height:1.2; } + .automation-row strong { font-size:inherit; } + .automation-row small { color:var(--muted); font-size:inherit; } + .automation-state { padding:0 6px; border-radius:999px; color:var(--amber); background:var(--amber-container); } + .automation-state[data-state="ok"] { color:var(--on-green-container); background:var(--green-container); } + .automation-row button { min-height:32px; padding:0 10px; font-size:24px; margin-left:auto; } + .automation-dialog { width:min(1000px,calc(100% - 32px)); max-height:85vh; overflow:auto; padding:20px; border:1px solid var(--outline); border-radius:24px; background:var(--surface); color:var(--text); font-size:24px; line-height:1.3; } + .automation-dialog::backdrop { background:#10131b88; } + .automation-dialog header { display:flex; justify-content:space-between; align-items:center; gap:12px; } + .automation-dialog h2 { font-size:28px; margin:0; } + .automation-dialog p, .automation-dialog li, .automation-dialog button { font-size:24px; } + .automation-dialog ul { padding-left:28px; } + @media (min-width:1000px) and (min-aspect-ratio:3/2) { + .automation-monitor { padding:4px 12px; gap:3px; } + #present .person-name { white-space:normal; overflow:visible; text-overflow:clip; overflow-wrap:anywhere; flex-shrink:0; } + #present .elapsed { flex-shrink:0; } + } + @media (min-width:1000px) and (min-aspect-ratio:3/2) and (max-height:800px) { + .app-shell { padding:12px 18px; } + .app-header { padding-bottom:6px; margin-bottom:6px; } + #cloud-snapshot { margin-bottom:4px; font-size:24px; } + .automation-monitor { margin-bottom:6px; } + main { grid-template-columns:minmax(0,2fr) minmax(350px,1fr); grid-template-rows:auto minmax(220px,1fr) minmax(180px,1fr) 0; grid-template-areas:"error error" "hero summary" "members wifi" "sync sync"; gap:6px 10px; } + .hero { grid-template-columns:140px minmax(0,1fr); } + .occupancy, .present-content { padding:8px; } + .present-content .section-heading { margin-bottom:4px; } + .cards { gap:4px; } + .person-card { padding:4px; } + .summary-card { padding:4px 8px; } + #wifi-section { padding:6px 8px; } + .automation-row { gap:2px 8px; } + } + `, + ); + return html; +} diff --git a/apps/attendance/scripts/internal-login.mjs b/apps/attendance/scripts/internal-login.mjs new file mode 100644 index 0000000..f979e6e --- /dev/null +++ b/apps/attendance/scripts/internal-login.mjs @@ -0,0 +1,445 @@ +// 클라우드 변환에만 삽입한다. LAN 렌더러와 인증 API의 권한 판정은 바꾸지 않는다. +export const INTERNAL_LOGIN_STYLES = ` +body:not([data-internal-auth="authenticated"]) #main, +body:not([data-internal-auth="authenticated"]) #monitor-view, +body:not([data-internal-auth="authenticated"]) #spotify-now, +body:not([data-internal-auth="authenticated"]) #cloud-snapshot, +body:not([data-internal-auth="authenticated"]) .automation-monitor, +body:not([data-internal-auth="authenticated"]) #am-dialog, +body:not([data-internal-auth="authenticated"]) #greeting-dialog, +body:not([data-internal-auth="authenticated"]) #viewer-name, +body:not([data-internal-auth="authenticated"]) #refresh, +body:not([data-internal-auth="authenticated"]) #logout:not([data-internal-logout-retry]) { display:none !important; } +body:not([data-internal-auth="authenticated"]) #login-panel { display:block !important; } +#login-panel { width:min(100%,620px); padding:24px; border-radius:28px; font-size:24px; } +#login-panel h2 { font-size:clamp(28px,3vw,36px); line-height:1.25; } +#login-panel p { font-size:24px; line-height:1.3; } +#login-panel label, #login-panel input, #login-panel button, #viewer-name, #logout { font-size:24px; } +body[data-internal-auth="authenticated"] #viewer-name { display:inline; } +#login-panel input, #login-panel button { min-height:52px; } +#login-panel .internal-session-check { margin-top:12px; } +#login-panel [aria-busy="true"] { cursor:progress; } +`; + +// 콜백은 동기적으로 실행한다. 중단·메모리 초기화는 기존 Pi/자동화 수명주기가 맡는다. +// 직렬화 후에도 동작하도록 모듈 바깥의 변수나 import를 참조하지 않는다. +export function createInternalLogin({ onAuthorized, onUnauthorized, clearPrivateState }) { + if ( + typeof onAuthorized !== 'function' || + typeof onUnauthorized !== 'function' || + typeof clearPrivateState !== 'function' + ) { + throw new Error('Internal login requires activity and private-state callbacks'); + } + const node = (id) => { + const element = document.getElementById(id); + if (!element) throw new Error('Internal login markup missing: ' + id); + return element; + }; + const panel = node('login-panel'); + const form = node('login-form'); + const student = node('login-student'); + const password = node('login-password'); + const submit = node('login-submit'); + const message = node('login-error'); + const logoutButton = node('logout'); + const viewerName = node('viewer-name'); + const main = node('main'); + const refreshButton = node('refresh'); + const retry = document.createElement('button'); + retry.type = 'button'; + retry.className = 'button-tonal internal-session-check'; + retry.textContent = '세션 다시 확인'; + form.appendChild(retry); + node('login-heading').textContent = 'Internal 계정 로그인'; + const description = panel.querySelector('p'); + if (description && description !== message) description.remove(); + student.setAttribute('inputmode', 'numeric'); + student.setAttribute('pattern', '[0-9]{8,10}'); + student.setAttribute('autocomplete', 'username'); + password.setAttribute('autocomplete', 'current-password'); + password.type = 'password'; + student.required = password.required = true; + let running = false; + let pageHidden = false; + let session = null; + let deadline = 0; + let revision = 0; + let pending = null; + let expiryTimer = null; + let checkTimer = null; + let logoutRequired = false; + const visible = () => !pageHidden && !document.hidden && navigator.onLine !== false; + + function paintBusy() { + const busy = pending !== null; + form.setAttribute('aria-busy', String(busy)); + submit.disabled = busy || !running || !visible(); + retry.disabled = busy || !running || !visible() || logoutRequired; + student.disabled = password.disabled = busy; + logoutButton.disabled = busy || !running || !visible(); + } + function clearPrivateDom() { + for (const id of ['present', 'members', 'monitor-cards', 'koin-services', 'am-recent']) { + const element = document.getElementById(id); + if (element) element.replaceChildren(); + if (element && (id === 'monitor-cards' || id === 'koin-services')) { + const placeholder = document.createElement('p'); + placeholder.className = 'monitor-waiting'; + placeholder.textContent = '인증 후 확인'; + element.appendChild(placeholder); + } + } + for (const id of ['count', 'today-total', 'week-total', 'unrecorded-total', 'members-badge']) { + const element = document.getElementById(id); + if (element) element.textContent = '—'; + } + for (const id of [ + 'member-count', + 'last-updated', + 'spotify-title', + 'spotify-artists', + 'am-note', + 'greeting-message', + ]) { + const element = document.getElementById(id); + if (element) element.textContent = ''; + } + for (const selector of ['#main img', '#monitor-view img', '#spotify-now img', '#greeting-dialog img']) { + for (const image of document.querySelectorAll(selector)) { + image.removeAttribute('src'); + image.removeAttribute('srcset'); + image.hidden = true; + } + } + for (const id of [ + 'am-ingress-state', + 'am-rocky-state', + 'am-ingress-time', + 'am-rocky-time', + 'am-sentry', + 'am-cloudflare', + 'am-sentry-count', + 'am-cloudflare-count', + 'am-cpu', + 'am-memory', + 'am-executor', + 'am-broker', + 'monitor-timestamp', + 'monitor-status', + ]) { + const element = document.getElementById(id); + if (element) { + element.textContent = '인증 후 확인'; + element.dataset.state = 'unknown'; + } + } + for (const id of ['am-dialog', 'greeting-dialog']) { + const dialog = document.getElementById(id); + if (dialog && dialog.open && typeof dialog.close === 'function') dialog.close(); + } + for (const selector of ['#main [data-since]', '#main [data-member-id]']) { + for (const element of document.querySelectorAll(selector)) { + element.removeAttribute('data-since'); + element.removeAttribute('data-member-id'); + } + } + const monitor = document.getElementById('monitor-view'); + if (monitor) monitor.hidden = true; + } + function lockView(text) { + document.body.dataset.internalAuth = logoutRequired ? 'logout-required' : 'signed-out'; + main.hidden = true; + panel.hidden = false; + viewerName.hidden = refreshButton.hidden = true; + viewerName.textContent = ''; + logoutButton.hidden = !logoutRequired; + logoutButton.toggleAttribute('data-internal-logout-retry', logoutRequired); + logoutButton.textContent = logoutRequired ? '로그아웃 다시 시도' : '로그아웃'; + password.value = ''; + message.textContent = text || ''; + } + function cancelRequest() { + const request = pending; + pending = null; + if (request) { + clearTimeout(request.timer); + request.controller.abort(); + } + } + function deny(text) { + revision += 1; + session = null; + deadline = 0; + clearTimeout(expiryTimer); + clearTimeout(checkTimer); + expiryTimer = checkTimer = null; + cancelRequest(); + lockView(text); + try { + onUnauthorized(); + } finally { + clearPrivateDom(); + clearPrivateState(); + paintBusy(); + } + } + function isAuthorized() { + if (!session) return false; + if (!running || !visible() || performance.now() >= deadline) { + deny(visible() ? '세션이 만료됐습니다. 다시 로그인해 주세요.' : '화면으로 돌아오면 계정을 다시 확인합니다.'); + return false; + } + return true; + } + function handleUnauthorized(responseOrStatus) { + const status = + typeof responseOrStatus === 'number' ? responseOrStatus : responseOrStatus && responseOrStatus.status; + if (status !== 401 && status !== 403) return false; + deny(status === 403 ? '이 계정의 접근 권한을 확인할 수 없습니다.' : '세션이 만료됐습니다. 다시 로그인해 주세요.'); + return true; + } + function scheduleChecks() { + clearTimeout(expiryTimer); + clearTimeout(checkTimer); + expiryTimer = setTimeout( + () => { + if (session && performance.now() < deadline) scheduleChecks(); + else deny('세션이 만료됐습니다. 다시 로그인해 주세요.'); + }, + Math.max(0, deadline - performance.now()), + ); + checkTimer = setTimeout(checkSession, Math.min(60000, Math.max(0, deadline - performance.now()))); + } + function accept(data, startedAt) { + if ( + !data || + data.authenticated !== true || + typeof data.memberId !== 'string' || + !data.memberId.trim() || + typeof data.name !== 'string' || + !data.name.trim() || + !Number.isSafeInteger(data.servedAt) || + data.servedAt <= 0 || + !Number.isSafeInteger(data.expiresAt) || + data.expiresAt <= data.servedAt || + data.expiresAt - data.servedAt > 900000 + ) { + deny('계정 유효기간을 확인할 수 없습니다. 다시 로그인해 주세요.'); + return false; + } + // 응답 본문을 받는 데 걸린 시간도 세션 잔여 시간에서 차감한다. + const nextDeadline = startedAt + data.expiresAt - data.servedAt; + if (!running || !visible() || performance.now() >= nextDeadline) { + deny('세션을 다시 확인해 주세요.'); + return false; + } + const resume = session === null; + if (session && session.memberId !== data.memberId) { + deny('계정이 변경됐습니다. 다시 로그인해 주세요.'); + return false; + } + session = { + authenticated: true, + memberId: data.memberId, + name: data.name, + expiresAt: data.expiresAt, + servedAt: data.servedAt, + }; + deadline = nextDeadline; + logoutRequired = false; + document.body.dataset.internalAuth = 'authenticated'; + panel.hidden = true; + main.hidden = false; + viewerName.textContent = data.name; + viewerName.hidden = logoutButton.hidden = refreshButton.hidden = false; + logoutButton.removeAttribute('data-internal-logout-retry'); + logoutButton.textContent = '로그아웃'; + message.textContent = ''; + scheduleChecks(); + if (resume) { + try { + onAuthorized({ ...session }); + } catch { + deny('화면을 시작할 수 없습니다. 세션을 다시 확인해 주세요.'); + return false; + } + } + return true; + } + async function requestAuth(kind, body) { + const paths = { session: '/api/me', login: '/api/login', logout: '/api/logout' }; + const controller = new AbortController(); + const request = { controller, timer: null }; + const currentRevision = revision; + const startedAt = performance.now(); + pending = request; + request.timer = setTimeout(() => controller.abort(), 8000); + paintBusy(); + try { + const response = await fetch(paths[kind], { + method: kind === 'session' ? 'GET' : 'POST', + credentials: 'same-origin', + cache: 'no-store', + redirect: 'error', + headers: + kind === 'session' + ? { accept: 'application/json' } + : { 'content-type': 'application/json', accept: 'application/json' }, + body: kind === 'logout' ? '{}' : body, + signal: controller.signal, + }); + const text = await response.text(); + if (pending !== request || revision !== currentRevision || !running) return null; + if (controller.signal.aborted) throw new Error('Authentication request timed out'); + return { status: response.status, data: response.ok && kind !== 'logout' ? JSON.parse(text) : null, startedAt }; + } catch (error) { + if (pending !== request || revision !== currentRevision || !running) return null; + throw error; + } finally { + clearTimeout(request.timer); + if (pending === request) pending = null; + paintBusy(); + } + } + async function checkSession() { + if (!running || pending || logoutRequired) return false; + if (!visible()) { + deny( + navigator.onLine === false + ? '오프라인입니다. 연결 후 계정을 다시 확인합니다.' + : '화면으로 돌아오면 계정을 다시 확인합니다.', + ); + return false; + } + try { + const result = await requestAuth('session'); + if (!result) return false; + if (result.status === 401) { + deny(''); + return false; + } + if (result.status !== 200) { + deny( + result.status === 403 + ? '이 계정의 접근 권한을 확인할 수 없습니다.' + : '인증 서비스를 사용할 수 없습니다. 세션을 다시 확인해 주세요.', + ); + return false; + } + return accept(result.data, result.startedAt); + } catch { + if (!pending && running && visible()) deny('계정 확인에 실패했습니다. 연결 후 세션을 다시 확인해 주세요.'); + return false; + } + } + async function login(event) { + event.preventDefault(); + if (!running || pending || session || !visible()) { + password.value = ''; + return; + } + if (!/^[0-9]{8,10}$/.test(student.value.trim()) || !password.value.trim()) { + password.value = ''; + message.textContent = '8~10자리 학번과 비밀번호를 입력해 주세요.'; + return; + } + message.textContent = '로그인 확인 중입니다.'; + try { + // 비밀번호는 이 요청 본문 외에는 보관하지 않으며 자동 재전송하지 않는다. + const resultPromise = requestAuth( + 'login', + JSON.stringify({ studentNumber: student.value.trim(), password: password.value }), + ); + password.value = ''; + const result = await resultPromise; + if (!result) return; + if (result.status === 401) { + deny('학번 또는 비밀번호를 확인해 주세요.'); + } else if (result.status === 403) { + deny('이 계정의 접근 권한을 확인할 수 없습니다.'); + } else if (result.status !== 200) { + deny('로그인 서비스를 사용할 수 없습니다. 잠시 뒤 직접 다시 시도해 주세요.'); + } else { + accept(result.data, result.startedAt); + } + } catch { + if (!pending && running && visible()) + deny('로그인 응답을 확인할 수 없습니다. 연결 후 세션을 확인하거나 직접 다시 로그인해 주세요.'); + } finally { + password.value = ''; + } + } + async function logout() { + if (!running) return false; + logoutRequired = true; + deny('로그아웃 요청 중입니다.'); + student.value = ''; + try { + const result = await requestAuth('logout'); + if (!result) return false; + if (result.status < 200 || result.status >= 300) throw new Error('Logout unavailable'); + logoutRequired = false; + deny('로그아웃했습니다. 출퇴근 기록은 변경되지 않습니다.'); + return true; + } catch { + if (!pending && running) deny('로그아웃을 완료하지 못했습니다. 연결 후 로그아웃을 다시 시도해 주세요.'); + return false; + } + } + function suspend() { + deny( + logoutRequired + ? '로그아웃을 완료하려면 연결 후 로그아웃을 다시 시도해 주세요.' + : navigator.onLine === false + ? '오프라인입니다. 연결 후 계정을 다시 확인합니다.' + : '화면으로 돌아오면 계정을 다시 확인합니다.', + ); + } + function resume() { + if (!running) return; + if (!visible()) suspend(); + else { + paintBusy(); + checkSession(); + } + } + function pagehide() { + pageHidden = true; + suspend(); + } + function pageshow() { + pageHidden = false; + resume(); + } + function start() { + if (running) return; + running = true; + form.addEventListener('submit', login); + retry.addEventListener('click', checkSession); + logoutButton.addEventListener('click', logout); + document.addEventListener('visibilitychange', resume); + window.addEventListener('offline', suspend); + window.addEventListener('online', resume); + window.addEventListener('pagehide', pagehide); + window.addEventListener('pageshow', pageshow); + deny('계정 확인 중입니다.'); + checkSession(); + } + function stop() { + running = false; + form.removeEventListener('submit', login); + retry.removeEventListener('click', checkSession); + logoutButton.removeEventListener('click', logout); + document.removeEventListener('visibilitychange', resume); + window.removeEventListener('offline', suspend); + window.removeEventListener('online', resume); + window.removeEventListener('pagehide', pagehide); + window.removeEventListener('pageshow', pageshow); + deny(''); + } + lockView(''); + clearPrivateDom(); + paintBusy(); + return { start, stop, isAuthorized, handleUnauthorized, checkSession, logout }; +} diff --git a/apps/attendance/snapshot/publish.py b/apps/attendance/snapshot/publish.py new file mode 100644 index 0000000..18878ef --- /dev/null +++ b/apps/attendance/snapshot/publish.py @@ -0,0 +1,681 @@ +#!/usr/bin/env python3 +"""허용된 LAN 조회 응답을 별도 Cloudflare KV에 게시하는 표준 라이브러리 CLI. + +기본 실행은 I/O 없는 계획 출력이며 --once/--loop가 실제 게시를 시작한다. +--validate는 네트워크·파일 쓰기 없이 명시한 인증과 설정만 검사한다. +기기 토큰은 목적에 맞게 제한하고, 계정 전체에 적용되는 KV 쓰기 권한의 +위험을 검토한다. 운영자 Wrangler 세션은 명시적인 PC 전용 모드로만 읽으며 +Pi에 복사하지 않는다. 만료 시 인증된 `wrangler kv namespace list`로 +정상 갱신한다. `whoami` 출력만으로 토큰이 갱신됐다고 간주하지 않는다. + +checkpoint는 영구 private 디렉터리에 보관한다. 쓰기 전에 시도 예산을 +원자적으로 예약하므로 모호한 실패도 일일 예산에 포함하고 성공 시각은 +진행시키지 않는다. 이 예산은 공유 계정의 무료 한도나 과금을 보장하지 않는다. +""" + +import argparse +import base64 +from contextlib import contextmanager +from datetime import datetime, timezone +import hashlib +import http.client +import json +import math +import os +from pathlib import Path +import re +import signal +import ssl +import stat +import sys +import tempfile +import threading +import time +import tomllib +import zlib + +ORIGIN_HOST = "192.168.1.112" +ORIGIN_PORT = 8080 +JSON_PATHS = ("/api/state", "/api/monitor", "/api/koin-health", "/api/worker-health", "/api/spotify", "/api/jam") +WIFI_PATHS = ("/api/wifi-qr/2.4", "/api/wifi-qr/5") +ASSET_PATHS = (*WIFI_PATHS, "/api/jam-qr") +READ_PATHS = frozenset((*JSON_PATHS, *ASSET_PATHS)) +SNAPSHOT_KEY = "clubroom:view:v1" +MAX_SNAPSHOT = 2 * 1024 * 1024 +KV_LIMIT = 25 * 1024 * 1024 +MAX_PNG = 65536 +MAX_REPLY = 65536 +MAX_SAFE = 9007199254740991 +DAILY_BUDGET = 600 +DEFAULT_INTERVAL = 180 +ID_PATTERN = re.compile(r"[a-fA-F0-9]{32}\Z") +TOKEN_PATTERN = re.compile(r"[A-Za-z0-9._~+/=-]{20,8192}\Z") + + +class PublisherError(Exception): + """자격증명·원문이 없는 고정 오류 메시지만 CLI 경계를 통과한다.""" + + +def now_ms(): + return time.time_ns() // 1_000_000 + + +def pairs_unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate key") + result[key] = value + return result + + +def parse_json(raw): + try: + return json.loads(raw.decode("utf-8"), object_pairs_hook=pairs_unique, + parse_constant=lambda _: (_ for _ in ()).throw(ValueError("nonfinite"))) + except (ValueError, UnicodeError, RecursionError): + raise PublisherError("invalid JSON response") from None + + +def encode_json(value): + try: + return json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode("utf-8") + except (ValueError, UnicodeError, RecursionError): + raise PublisherError("invalid snapshot encoding") from None + + +def exact(value, keys): + return isinstance(value, dict) and set(value) == set(keys.split()) + + +def integer(value, minimum=0): + return type(value) is int and minimum <= value <= MAX_SAFE + + +def timestamp(value): + return value is None or integer(value) + + +def text(value, limit=256): + return (isinstance(value, str) and len(value) <= limit + and not any(ord(c) < 32 or 0x7f <= ord(c) < 0xa0 or 0xd800 <= ord(c) <= 0xdfff for c in value)) + + +def valid_cover(value): + if value is None: + return True + if not isinstance(value, str) or len(value) > 512: + return False + return re.fullmatch(r"https://i\.scdn\.co(?::443)?/image/[A-Za-z0-9]{1,128}", value) is not None + + +def valid_state(body): + if not exact(body, "now members unknownCards recent") or not integer(body["now"], 1): + return False + if body["unknownCards"] != [] or not isinstance(body["members"], list) or not isinstance(body["recent"], list): + return False + for member in body["members"]: + if (not exact(member, "name present since todayMs weekMs unrecordedWeek firstInToday lastCheckOut") + or not text(member["name"], 100) or not member["name"] or type(member["present"]) is not bool + or not all(timestamp(member[key]) for key in ("since", "firstInToday", "lastCheckOut")) + or not all(integer(member[key]) for key in ("todayMs", "weekMs", "unrecordedWeek"))): + return False + for event in body["recent"]: + if (not exact(event, "name checkIn checkOut unrecorded") or not text(event["name"], 100) + or not integer(event["checkIn"], 1) or not timestamp(event["checkOut"]) + or type(event["unrecorded"]) is not bool): + return False + return True + + +def valid_optional(path, body): + # 비밀 필드가 섞인 응답은 거부하고 허용된 응답·원본 시각은 그대로 유지한다. + if path == "/api/jam": + return exact(body, "available") and type(body["available"]) is bool + if path == "/api/spotify": + return (exact(body, "state title artists updatedAt coverUrl") + and body["state"] in ("idle", "loading", "playing", "paused", "error", "unknown") + and text(body["title"]) and isinstance(body["artists"], list) and len(body["artists"]) <= 8 + and all(text(artist, 128) for artist in body["artists"]) + and timestamp(body["updatedAt"]) and valid_cover(body["coverUrl"])) + if path == "/api/monitor": + if not exact(body, "observedAt nodes") or not integer(body["observedAt"], 1) or not isinstance(body["nodes"], list): + return False + seen = set() + for node in body["nodes"]: + if (not exact(node, "id label collectedAt cpuPercent memoryUsedBytes memoryTotalBytes health") + or node["id"] not in ("prod", "stage", "db") or node["id"] in seen + or not text(node["label"], 100) or node["health"] not in ("up", "down", "unknown") + or not timestamp(node["collectedAt"]) + or not all(timestamp(node[key]) for key in ("memoryUsedBytes", "memoryTotalBytes"))): + return False + cpu = node["cpuPercent"] + if cpu is not None and (type(cpu) not in (int, float) or not math.isfinite(cpu) or not 0 <= cpu <= 100): + return False + seen.add(node["id"]) + return seen == {"prod", "stage", "db"} + if path == "/api/koin-health": + if not exact(body, "observedAt services") or not integer(body["observedAt"], 1) or not isinstance(body["services"], list): + return False + rows, fields, identities = body["services"], "id label checkedAt status reason httpStatus", {"prod-api", "stage-api"} + elif path == "/api/worker-health": + if not exact(body, "checkedAt workers") or not integer(body["checkedAt"], 1) or not isinstance(body["workers"], list): + return False + rows, fields, identities = body["workers"], "id label status httpStatus reason", {"koin-web"} + else: + return False + seen = set() + for row in rows: + if (not exact(row, fields) or row["id"] not in identities or row["id"] in seen + or not text(row["label"], 100) or row["status"] not in ("up", "down", "unknown") + or row["reason"] not in ("unavailable", "http", "invalid-response", "ready", "not-ready", "tls", "timeout", "redirect", "restricted") + or (row["httpStatus"] is not None and (type(row["httpStatus"]) is not int or not 100 <= row["httpStatus"] <= 599)) + or ("checkedAt" in row and not integer(row["checkedAt"], 1))): + return False + seen.add(row["id"]) + return seen == identities + + +def valid_png(raw): + """제한된 PNG 청크·CRC·비인터레이스 스캔라인을 검사한다.""" + if not 8 < len(raw) <= MAX_PNG or raw[:8] != b"\x89PNG\r\n\x1a\n": + return False + offset, header, palette, ended = 8, None, False, False + image = bytearray() + while offset + 12 <= len(raw): + size = int.from_bytes(raw[offset:offset + 4], "big") + kind = raw[offset + 4:offset + 8] + end = offset + size + 12 + if (end > len(raw) or not all(65 <= c <= 90 or 97 <= c <= 122 for c in kind) + or zlib.crc32(raw[offset + 4:end - 4]) != int.from_bytes(raw[end - 4:end], "big")): + return False + data = raw[offset + 8:end - 4] + if header is None: + if kind != b"IHDR" or size != 13: + return False + width, height = int.from_bytes(data[:4], "big"), int.from_bytes(data[4:8], "big") + depth, color, compression, filtering, interlace = data[8:] + if (not 1 <= width <= 2048 or not 1 <= height <= 2048 + or depth not in {0: (1, 2, 4, 8, 16), 2: (8, 16), 3: (1, 2, 4, 8), 4: (8, 16), 6: (8, 16)}.get(color, ()) + or compression or filtering or interlace): + return False + row_bytes = (width * {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}[color] * depth + 7) // 8 + expected = (row_bytes + 1) * height + if expected > 16 * 1024 * 1024: + return False + header = (color, depth, row_bytes, expected) + elif kind == b"IHDR": + return False + elif kind == b"PLTE": + if palette or image or color in (0, 4) or not size or size % 3 or size > 768 or (color == 3 and size // 3 > 2 ** depth): + return False + palette = True + elif kind == b"IDAT": + if ended or (color == 3 and not palette): + return False + image.extend(data) + elif kind == b"IEND": + if size or end != len(raw) or not image: + return False + try: + decoder = zlib.decompressobj() + decoded = decoder.decompress(image, expected + 1) + return (len(decoded) == expected and decoder.eof and not decoder.unused_data + and not decoder.unconsumed_tail + and all(decoded[index] <= 4 for index in range(0, expected, row_bytes + 1))) + except zlib.error: + return False + elif not kind[0] & 0x20: + return False + if image and kind != b"IDAT": + ended = True + offset = end + return False + + +def fetch_lan(path, *, ssh_forward=False): + if path not in READ_PATHS: + raise PublisherError("LAN target is not allowed") + limit = MAX_SNAPSHOT if path == "/api/state" else MAX_PNG if path in ASSET_PATHS else MAX_REPLY + # SSH 전용 관리자 경로에서는 명시한 loopback 전달을 사용하되 Host는 유지한다. + connection = http.client.HTTPConnection("127.0.0.1" if ssh_forward else ORIGIN_HOST, + ORIGIN_PORT, timeout=12) + try: + connection.request("GET", path, headers={"Host": f"{ORIGIN_HOST}:{ORIGIN_PORT}", + "Accept": "image/png" if path in ASSET_PATHS else "application/json", "Cache-Control": "no-store"}) + response = connection.getresponse() + # 리디렉션이나 HTTP_PROXY 환경 변수로 목적지를 바꾸지 않는다. + status, mime = response.status, response.getheader("Content-Type", "").split(";", 1)[0].strip().lower() + if status != 200: + return status, mime, b"" # 서버 오류 원문은 읽거나 전달하지 않는다. + raw = response.read(limit + 1) + if len(raw) > limit: + raise PublisherError("LAN response exceeds size bound") + return status, mime, raw + except (OSError, http.client.HTTPException): + raise PublisherError("LAN request unavailable") from None + finally: + connection.close() + + +def collect_snapshot(fetch=fetch_lan, clock=now_ms): + payloads = {} + for path in JSON_PATHS: + try: + status, mime, raw = fetch(path) + if status != 200: + if path == "/api/state": + raise PublisherError("attendance core unavailable") + payloads[path] = {"status": status if type(status) is int and 400 <= status <= 599 else 502, + "body": {"error": "source_unavailable"}} + continue + body = parse_json(raw) if mime == "application/json" else None + valid = valid_state(body) if path == "/api/state" else valid_optional(path, body) + if not valid: + raise PublisherError("invalid source schema") + payloads[path] = {"status": 200, "body": body} + except (PublisherError, TypeError, KeyError, ValueError, RecursionError): + if path == "/api/state": + raise PublisherError("attendance core unavailable or invalid") from None + payloads[path] = {"status": 502, "body": {"error": "source_unavailable"}} + assets = {} + jam = payloads["/api/jam"] + paths = (*WIFI_PATHS, "/api/jam-qr") if jam["status"] == 200 and jam["body"]["available"] else WIFI_PATHS + for path in paths: + try: + status, mime, raw = fetch(path) + if status != 200 or mime != "image/png" or not valid_png(raw): + raise PublisherError("required QR image unavailable or invalid") + except PublisherError: + if path in WIFI_PATHS: + raise + # 실제 QR이 없으면 Jam을 사용 가능으로 광고하지 않는다. + payloads["/api/jam"] = {"status": 502, "body": {"error": "source_unavailable"}} + continue + assets[path] = base64.b64encode(raw).decode("ascii") + observed = payloads["/api/state"]["body"]["now"] + published = clock() + if observed > published or published - observed > 60_000: + raise PublisherError("attendance observation is stale or in the future") + snapshot = {"version": 1, "observedAt": observed, "publishedAt": published, "payloads": payloads, "assets": assets} + encoded = encode_json(snapshot) + if len(encoded) > min(MAX_SNAPSHOT, KV_LIMIT): + raise PublisherError("snapshot exceeds 2 MiB bound") + return snapshot, encoded + + +def windows_security(path, apply=False): + """Windows에서는 chmod 대신 실제 소유자·DACL을 검사한다.""" + import ctypes + from ctypes import wintypes as w + adv = ctypes.WinDLL("advapi32", use_last_error=True) + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + adv.OpenProcessToken.argtypes = [w.HANDLE, w.DWORD, ctypes.POINTER(w.HANDLE)] + adv.GetTokenInformation.argtypes = [w.HANDLE, ctypes.c_int, w.LPVOID, w.DWORD, ctypes.POINTER(w.DWORD)] + adv.ConvertSidToStringSidW.argtypes = [w.LPVOID, ctypes.POINTER(w.LPWSTR)] + adv.ConvertStringSecurityDescriptorToSecurityDescriptorW.argtypes = [w.LPCWSTR, w.DWORD, ctypes.POINTER(w.LPVOID), ctypes.POINTER(w.DWORD)] + adv.SetFileSecurityW.argtypes = [w.LPCWSTR, w.DWORD, w.LPVOID] + adv.GetNamedSecurityInfoW.argtypes = [w.LPWSTR, ctypes.c_int, w.DWORD, ctypes.POINTER(w.LPVOID), ctypes.POINTER(w.LPVOID), ctypes.POINTER(w.LPVOID), ctypes.POINTER(w.LPVOID), ctypes.POINTER(w.LPVOID)] + adv.GetAce.argtypes = [w.LPVOID, w.DWORD, ctypes.POINTER(w.LPVOID)] + kernel.GetCurrentProcess.restype = w.HANDLE + kernel.CloseHandle.argtypes = [w.HANDLE] + kernel.LocalFree.argtypes = [w.LPVOID] + def sid_string(sid): + result = w.LPWSTR() + if not adv.ConvertSidToStringSidW(sid, ctypes.byref(result)): + raise PublisherError("cannot inspect Windows private ACL") + try: + return result.value + finally: + kernel.LocalFree(ctypes.cast(result, w.LPVOID)) + token, needed = w.HANDLE(), w.DWORD() + if not adv.OpenProcessToken(kernel.GetCurrentProcess(), 8, ctypes.byref(token)): + raise PublisherError("cannot inspect Windows identity") + try: + adv.GetTokenInformation(token, 1, None, 0, ctypes.byref(needed)) + buffer = ctypes.create_string_buffer(needed.value) + if not adv.GetTokenInformation(token, 1, buffer, needed, ctypes.byref(needed)): + raise PublisherError("cannot inspect Windows identity") + current_sid = sid_string(ctypes.cast(buffer, ctypes.POINTER(w.LPVOID))[0]) + finally: + kernel.CloseHandle(token) + descriptor = w.LPVOID() + if apply: + # 하위 항목에만 상속하며 Users·Everyone 권한은 허용하지 않는다. + sddl = f"D:P(A;OICI;FA;;;{current_sid})(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" + if not adv.ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl, 1, ctypes.byref(descriptor), None): + raise PublisherError("cannot create Windows private ACL") + try: + if not adv.SetFileSecurityW(str(path), 0x80000004, descriptor): + raise PublisherError("cannot apply Windows private ACL") + finally: + kernel.LocalFree(descriptor) + owner, acl, descriptor = w.LPVOID(), w.LPVOID(), w.LPVOID() + if adv.GetNamedSecurityInfoW(str(path), 1, 5, ctypes.byref(owner), None, ctypes.byref(acl), None, ctypes.byref(descriptor)): + raise PublisherError("cannot inspect Windows private ACL") + try: + allowed = {current_sid, "S-1-5-18", "S-1-5-32-544"} + if not owner or sid_string(owner) not in allowed or not acl: + raise PublisherError("Windows file must have a private owner and DACL") + # ACL 헤더의 ACE 개수 필드를 읽는다. + count = ctypes.c_ushort.from_address(acl.value + 4).value + for index in range(count): + ace = w.LPVOID() + if not adv.GetAce(acl, index, ctypes.byref(ace)): + raise PublisherError("cannot inspect Windows private ACL") + kind = ctypes.c_ubyte.from_address(ace.value).value + if kind == 1: # A deny ACE cannot grant access. + continue + if kind != 0 or sid_string(ace.value + 8) not in allowed: + raise PublisherError("Windows file grants access outside its private principals") + finally: + kernel.LocalFree(descriptor) + + +def private_path(path, directory=False): + try: + info = path.lstat() + if (path.is_symlink() or getattr(info, "st_file_attributes", 0) & 0x400 + or not (stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode)) + or (not directory and info.st_nlink != 1)): + raise PublisherError("private path must be a regular non-linked file or directory") + if os.name == "nt": + windows_security(path) + elif info.st_uid not in (0, os.geteuid()) or info.st_mode & 0o077: + raise PublisherError("private path must be owner-only (0700 directory / 0600 file)") + except OSError: + raise PublisherError("private path is inaccessible") from None + + +def private_read(path, limit): + private_path(path) + try: + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) + before = path.lstat() + with os.fdopen(os.open(path, flags), "rb") as stream: + actual = os.fstat(stream.fileno()) + if (before.st_dev, before.st_ino) != (actual.st_dev, actual.st_ino) or actual.st_size > limit: + raise PublisherError("private file changed or exceeds size bound") + raw = stream.read(limit + 1) + if len(raw) > limit: + raise PublisherError("private file exceeds size bound") + return raw + except OSError: + raise PublisherError("private file is inaccessible") from None + + +def read_credential(args, clock=now_ms): + try: + if args.operator_wrangler_auth_file: + data = tomllib.loads(private_read(Path(args.operator_wrangler_auth_file), 65536).decode("utf-8")) + value = data.get("oauth_token") + expiry = data.get("expiration_time") + expiry = datetime.fromisoformat(expiry.replace("Z", "+00:00")) if isinstance(expiry, str) else expiry + if not isinstance(expiry, datetime) or expiry.tzinfo is None or expiry.timestamp() * 1000 <= clock() + 60_000: + raise PublisherError("operator OAuth expired or missing expiry; run authenticated Wrangler kv namespace list on the authorized PC") + elif args.token_file: + value = private_read(Path(args.token_file), 8193).decode("ascii").strip() + else: + value = os.environ.get(args.token_env, "") + if not isinstance(value, str) or not TOKEN_PATTERN.fullmatch(value): + raise PublisherError("credential is missing or invalid") + return value + except (OSError, ValueError, UnicodeError, TypeError, OverflowError): + raise PublisherError("credential file is missing or invalid") from None + + +def destination(account, namespace): + if not isinstance(account, str) or not ID_PATTERN.fullmatch(account) or not isinstance(namespace, str) or not ID_PATTERN.fullmatch(namespace): + raise PublisherError("explicit 32-hex account and existing dedicated namespace are required") + return f"/client/v4/accounts/{account.lower()}/storage/kv/namespaces/{namespace.lower()}/values/{SNAPSHOT_KEY}" + + +def put_snapshot(account, namespace, credential, body): + path = destination(account, namespace) + if not isinstance(body, bytes) or not 0 < len(body) <= min(MAX_SNAPSHOT, KV_LIMIT): + raise PublisherError("snapshot exceeds write bound") + connection = http.client.HTTPSConnection("api.cloudflare.com", 443, timeout=20, context=ssl.create_default_context()) + try: + connection.request("PUT", path, body=body, headers={"Authorization": "Bearer " + credential, + "Content-Type": "application/json", "Accept": "application/json"}) + response = connection.getresponse() + if not 200 <= response.status <= 299: + raise PublisherError("Cloudflare KV write rejected") + raw = response.read(MAX_REPLY + 1) + if len(raw) > MAX_REPLY: + raise PublisherError("Cloudflare KV reply exceeds size bound") + result = parse_json(raw) + if not isinstance(result, dict) or result.get("success") is not True: + raise PublisherError("Cloudflare KV write not confirmed") + except (OSError, http.client.HTTPException): + raise PublisherError("Cloudflare KV write unavailable or unconfirmed") from None + finally: + connection.close() + + +def ensure_directory(path): + try: + if not path.exists(): + # 안전하지 않은 부모 디렉터리를 새로 만들지 않는다. + path.mkdir(mode=0o700) + if os.name == "nt": + windows_security(path, apply=True) + private_path(path, directory=True) + except OSError: + raise PublisherError("checkpoint directory unavailable") from None + + +def create_private_file(path): + try: + fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError: + private_path(path) + return os.open(path, os.O_RDWR | getattr(os, "O_NOFOLLOW", 0)) + try: + if os.name == "nt": + windows_security(path, apply=True) + return fd + except BaseException: + os.close(fd) + raise + + +@contextmanager +def destination_lock(directory, identity): + path = directory / (identity + ".lock") + fd = create_private_file(path) + try: + with os.fdopen(fd, "r+b", buffering=0) as stream: + if os.name == "nt": + import msvcrt + if os.fstat(stream.fileno()).st_size == 0: + stream.write(b"\0") + stream.seek(0) + try: + msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1) + except OSError: + raise PublisherError("another publisher holds the destination lock") from None + else: + import fcntl + try: + fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + raise PublisherError("another publisher holds the destination lock") from None + yield + except OSError: + raise PublisherError("checkpoint lock unavailable") from None + + +def atomic_checkpoint(path, value): + temporary = None + try: + fd, temporary = tempfile.mkstemp(prefix=".snapshot-", dir=path.parent) + with os.fdopen(fd, "wb") as stream: + if os.name == "nt": + windows_security(Path(temporary), apply=True) + stream.write(encode_json(value)) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + temporary = None + if os.name != "nt": + fd = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + try: + os.fsync(fd) + finally: + os.close(fd) + except OSError: + raise PublisherError("atomic checkpoint failed; publication stopped") from None + finally: + if temporary is not None: + try: + os.unlink(temporary) + except OSError: + pass + + +def utc_day(at): + return datetime.fromtimestamp(at / 1000, timezone.utc).date().isoformat() + + +def load_checkpoint(path, target, at): + if not path.exists() and not path.is_symlink(): + return {"version": 1, "destination": target, "day": utc_day(at), "attempts": 0, + "lastAttemptAt": None, "lastPublishedAt": None} + value = parse_json(private_read(path, MAX_REPLY)) + if (not exact(value, "version destination day attempts lastAttemptAt lastPublishedAt") + or value["version"] != 1 or value["destination"] != target + or not isinstance(value["day"], str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value["day"]) + or type(value["attempts"]) is not int or not 0 <= value["attempts"] <= DAILY_BUDGET + or not timestamp(value["lastAttemptAt"]) or not timestamp(value["lastPublishedAt"])): + raise PublisherError("checkpoint malformed or destination mismatch") + try: + datetime.strptime(value["day"], "%Y-%m-%d") + if (value["attempts"] > 0 and (value["lastAttemptAt"] is None + or utc_day(value["lastAttemptAt"]) != value["day"])): + raise ValueError("inconsistent reservation") + if (value["lastPublishedAt"] is not None and (value["lastAttemptAt"] is None + or value["lastPublishedAt"] > value["lastAttemptAt"])): + raise ValueError("inconsistent publication") + except (ValueError, OverflowError, OSError): + raise PublisherError("checkpoint malformed or inconsistent") from None + if value["day"] > utc_day(at) or (value["lastAttemptAt"] is not None and value["lastAttemptAt"] > at): + raise PublisherError("UTC clock moved backwards; refusing budget reset") + if value["day"] != utc_day(at): + value = {**value, "day": utc_day(at), "attempts": 0} + return value + + +def reserve_checkpoint(value, at, interval): + if value["day"] != utc_day(at): + if value["day"] > utc_day(at): + raise PublisherError("UTC clock moved backwards") + value = {**value, "day": utc_day(at), "attempts": 0} + if value["attempts"] >= DAILY_BUDGET: + raise PublisherError("600-attempt UTC daily publication budget exhausted") + if value["lastAttemptAt"] is not None and at - value["lastAttemptAt"] < interval * 1000: + raise PublisherError("publication cadence guard; wait for the configured interval") + return {**value, "attempts": value["attempts"] + 1, "lastAttemptAt": at} + + +def publish_once(args, fetch=fetch_lan, put=put_snapshot, clock=now_ms): + target = destination(args.account_id, args.namespace_id) + credential = read_credential(args, clock) + directory = Path(args.checkpoint_dir) + ensure_directory(directory) + identity = hashlib.sha256(target.encode("ascii")).hexdigest() + checkpoint = directory / (identity + ".json") + with destination_lock(directory, identity): + value = load_checkpoint(checkpoint, target, clock()) + # LAN 접속 전 예산·간격을 확인하고 쓰기 예약 시 다시 대조한다. + reserve_checkpoint(value, clock(), args.interval) + snapshot, raw = collect_snapshot(fetch, clock) + reserved = reserve_checkpoint(value, clock(), args.interval) + atomic_checkpoint(checkpoint, reserved) + put(args.account_id, args.namespace_id, credential, raw) + confirmed = {**reserved, "lastPublishedAt": snapshot["publishedAt"]} + atomic_checkpoint(checkpoint, confirmed) + return {"published": True, "observedAt": snapshot["observedAt"], "publishedAt": snapshot["publishedAt"], + "bytes": len(raw), "utcAttempts": confirmed["attempts"]} + + +def parser(): + result = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + modes = result.add_mutually_exclusive_group() + modes.add_argument("--plan", action="store_true", help="default: report fixed plan without I/O") + modes.add_argument("--validate", action="store_true", help="validate explicit destination and credential; no I/O to LAN/cloud") + modes.add_argument("--once", action="store_true", help="publish one snapshot to the existing namespace") + modes.add_argument("--loop", action="store_true", help="publish until SIGINT/SIGTERM; no service installation") + result.add_argument("--account-id") + result.add_argument("--namespace-id") + auth = result.add_mutually_exclusive_group() + auth.add_argument("--token-env", metavar="NAME", help="explicit environment variable containing machine KV token") + auth.add_argument("--token-file", help="private plaintext machine KV token file") + auth.add_argument("--operator-wrangler-auth-file", help="private existing Wrangler TOML session on operator PC only (broad OAuth)") + result.add_argument("--checkpoint-dir", help="persistent private directory; parent must exist") + result.add_argument("--interval", type=int, default=DEFAULT_INTERVAL, help="publication seconds, >=180; default 180 (~480/day)") + result.add_argument("--ssh-forward", action="store_true", help="operator source through an existing 127.0.0.1:8080 SSH forward to Pi5 localhost:8080") + return result + + +def validate_arguments(args): + destination(args.account_id, args.namespace_id) + if args.interval < DEFAULT_INTERVAL: + raise PublisherError("publication interval must be at least 180 seconds") + if not any((args.token_env, args.token_file, args.operator_wrangler_auth_file)): + raise PublisherError("an explicit machine credential source or operator session is required") + if args.token_env and not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", args.token_env): + raise PublisherError("invalid credential environment variable name") + if not args.checkpoint_dir: + raise PublisherError("explicit persistent checkpoint directory is required") + + +def main(argv=None): + args = parser().parse_args(argv) + if not (args.validate or args.once or args.loop): + print(json.dumps({"mode": "plan", "origin": f"http://{ORIGIN_HOST}:{ORIGIN_PORT}", "paths": sorted(READ_PATHS), + "key": SNAPSHOT_KEY, "intervalSeconds": args.interval, "utcAttemptBudget": DAILY_BUDGET, + "maxSnapshotBytes": MAX_SNAPSHOT, "resourcesCreated": False, + "required": "explicit existing account/namespace, credential source, persistent private checkpoint directory", + "quota": "account free quota is shared; zero billing is not guaranteed"})) + return 0 + try: + validate_arguments(args) + if args.validate: + read_credential(args) + directory = Path(args.checkpoint_dir) + if directory.exists(): + private_path(directory, directory=True) + else: + raise PublisherError("validation requires an existing private checkpoint directory") + print(json.dumps({"valid": True, "credentialMode": "operator-broad-oauth" if args.operator_wrangler_auth_file else "machine-kv-token", "networkContacted": False})) + return 0 + stopped = threading.Event() + def stop(_signum, _frame): + stopped.set() + if args.loop: + signal.signal(signal.SIGINT, stop) + signal.signal(signal.SIGTERM, stop) + def fetch(path): + return fetch_lan(path, ssh_forward=args.ssh_forward) + while not stopped.is_set(): + try: + print(json.dumps(publish_once(args, fetch=fetch)), flush=True) + except PublisherError as error: + print("snapshot publisher: " + str(error), file=sys.stderr, flush=True) + if not args.loop: + return 1 + if not args.loop or stopped.wait(args.interval): + break + return 0 + except PublisherError as error: + print("snapshot publisher: " + str(error), file=sys.stderr) + return 1 + except (OSError, ValueError, TypeError, RecursionError, OverflowError): + # 예외 원문·자격증명·응답 본문·헤더를 로그에 남기지 않는다. + print("snapshot publisher: local configuration or I/O failure", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/attendance/snapshot/test_publish.py b/apps/attendance/snapshot/test_publish.py new file mode 100644 index 0000000..a01c46a --- /dev/null +++ b/apps/attendance/snapshot/test_publish.py @@ -0,0 +1,444 @@ +"""Offline stdlib tests. Run explicitly with unittest; never contacts LAN/cloud.""" +import argparse +import base64 +from copy import deepcopy +from contextlib import redirect_stderr, redirect_stdout +from datetime import datetime, timezone +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import struct +import tempfile +import unittest +from unittest.mock import patch +import zlib + +spec = importlib.util.spec_from_file_location("attendance_snapshot_publish", Path(__file__).with_name("publish.py")) +publisher = importlib.util.module_from_spec(spec) +spec.loader.exec_module(publisher) + +ACCOUNT = "a" * 32 +NAMESPACE = "b" * 32 +TOKEN = "machine-secret-do-not-disclose-1234567890" +AT = 1_800_000_000_000 + + +def png(width=1, height=1): + def chunk(kind, data): + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 0, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress((b"\0" + b"\xff" * width) * height)) + chunk(b"IEND", b"")) + + +def source_bodies(): + return { + "/api/state": {"now": AT, "members": [{"name": "실제 회원", "present": True, "since": AT - 600_000, + "todayMs": 600_000, "weekMs": 1_200_000, "unrecordedWeek": 2, + "firstInToday": AT - 600_000, "lastCheckOut": None}], "unknownCards": [], + "recent": [{"name": "실제 회원", "checkIn": AT - 600_000, "checkOut": None, "unrecorded": False}]}, + "/api/monitor": {"observedAt": AT - 10_000, "nodes": [{"id": identity, "label": identity, + "collectedAt": AT - 12_000, "cpuPercent": 23.75, "memoryUsedBytes": 123456, + "memoryTotalBytes": 654321, "health": "up"} for identity in ("prod", "stage", "db")]}, + "/api/koin-health": {"observedAt": AT - 2000, "services": [{"id": identity, "label": identity, + "checkedAt": AT - 2500, "status": "up", "reason": "ready", "httpStatus": 200} + for identity in ("prod-api", "stage-api")]}, + "/api/worker-health": {"checkedAt": AT - 5000, "workers": [{"id": "koin-web", "label": "WEB", + "status": "unknown", "httpStatus": 403, "reason": "restricted"}]}, + "/api/spotify": {"state": "playing", "title": "Real track", "artists": ["Real artist"], + "updatedAt": AT - 7000, "coverUrl": "https://i.scdn.co/image/0123abc"}, + "/api/jam": {"available": False}, + } + + +class FakeLAN: + def __init__(self): + self.bodies = source_bodies() + self.overrides = {} + self.calls = [] + + def __call__(self, path): + self.calls.append(path) + if path in self.overrides: + override = self.overrides[path] + if isinstance(override, Exception): + raise override + return override + if path in self.bodies: + return 200, "application/json", publisher.encode_json(self.bodies[path]) + if path in publisher.ASSET_PATHS: + return 200, "image/png", png() + raise AssertionError("unexpected LAN endpoint") + + +def arguments(directory): + return argparse.Namespace(account_id=ACCOUNT, namespace_id=NAMESPACE, token_env="SNAPSHOT_TEST_TOKEN", + token_file=None, operator_wrangler_auth_file=None, + checkpoint_dir=str(directory), interval=180) + + +def make_private(path, directory=False): + if os.name == "nt": + publisher.windows_security(path, apply=True) + else: + path.chmod(0o700 if directory else 0o600) + + +class SnapshotTests(unittest.TestCase): + def test_actual_data_and_timestamps_are_not_mutated(self): + lan = FakeLAN() + original = deepcopy(lan.bodies) + snapshot, raw = publisher.collect_snapshot(lan, lambda: AT + 8000) + self.assertEqual(lan.bodies, original) + self.assertEqual(snapshot["observedAt"], AT) + self.assertEqual(snapshot["publishedAt"], AT + 8000) + for path in publisher.JSON_PATHS: + self.assertEqual(snapshot["payloads"][path], {"status": 200, "body": original[path]}) + self.assertEqual(json.loads(raw), snapshot) + self.assertEqual(base64.b64decode(snapshot["assets"]["/api/wifi-qr/5"]), png()) + self.assertNotIn("/api/jam-qr", snapshot["assets"]) + self.assertNotIn("/api/jam-qr", lan.calls) + self.assertEqual(set(lan.calls), set(publisher.JSON_PATHS + publisher.WIFI_PATHS)) + + def test_core_invalid_or_unavailable_aborts(self): + cases = [(503, "text/html", b"secret"), (200, "text/html", b"secret"), + (200, "application/json", b'{"now":1,"now":2}'), + (200, "application/json", b'{"now":NaN}'), + (200, "application/json", b"[]"), (200, "application/json", b"\xff")] + for response in cases: + with self.subTest(response=response[:2]): + lan = FakeLAN() + lan.overrides["/api/state"] = response + with self.assertRaises(publisher.PublisherError): + publisher.collect_snapshot(lan, lambda: AT) + for field in ("studentNumber", "queue", "deviceToken", "greeting"): + lan = FakeLAN() + lan.bodies["/api/state"][field] = "do-not-publish" + with self.assertRaises(publisher.PublisherError): + publisher.collect_snapshot(lan, lambda: AT) + + def test_optional_error_status_preserved_but_secret_body_discarded(self): + lan = FakeLAN() + lan.overrides["/api/monitor"] = (401, "application/json", b'{"token":"do-not-publish"}') + lan.overrides["/api/koin-health"] = (503, "text/html", b"private diagnostics") + lan.overrides["/api/spotify"] = (302, "text/plain", b"https://secret.example") + lan.bodies["/api/worker-health"]["credentials"] = TOKEN + snapshot, raw = publisher.collect_snapshot(lan, lambda: AT) + self.assertEqual(snapshot["payloads"]["/api/monitor"]["status"], 401) + self.assertEqual(snapshot["payloads"]["/api/koin-health"]["status"], 503) + self.assertEqual(snapshot["payloads"]["/api/spotify"]["status"], 502) + self.assertEqual(snapshot["payloads"]["/api/worker-health"]["status"], 502) + for secret in ("do-not-publish", "private diagnostics", "secret.example", TOKEN): + self.assertNotIn(secret.encode(), raw) + + def test_optional_malformed_json_or_untrusted_cover_is_unavailable(self): + for body in (b"{", b'{"state":"playing","state":"idle"}', b'{"state":Infinity}'): + lan = FakeLAN() + lan.overrides["/api/spotify"] = (200, "application/json", body) + snapshot, _ = publisher.collect_snapshot(lan, lambda: AT) + self.assertEqual(snapshot["payloads"]["/api/spotify"]["status"], 502) + for cover in ("http://i.scdn.co/image/abc", "https://i.scdn.co.evil/image/a", "https://user:password@i.scdn.co/image/a", + "https://i.scdn.co/image/a?token=secret", "https://192.168.1.112/camera"): + lan = FakeLAN() + lan.bodies["/api/spotify"]["coverUrl"] = cover + snapshot, raw = publisher.collect_snapshot(lan, lambda: AT) + self.assertEqual(snapshot["payloads"]["/api/spotify"]["status"], 502) + self.assertNotIn(cover.encode(), raw) + + def test_wifi_absence_aborts_and_jam_is_never_invented(self): + for response in ((503, "application/json", b"{}"), (200, "image/png", b"fake PNG")): + lan = FakeLAN() + lan.overrides["/api/wifi-qr/2.4"] = response + with self.assertRaises(publisher.PublisherError): + publisher.collect_snapshot(lan, lambda: AT) + lan = FakeLAN() + lan.bodies["/api/jam"]["available"] = True + snapshot, _ = publisher.collect_snapshot(lan, lambda: AT) + self.assertIn("/api/jam-qr", snapshot["assets"]) + lan.overrides["/api/jam-qr"] = (404, "application/json", b"{}") + snapshot, _ = publisher.collect_snapshot(lan, lambda: AT) + self.assertNotIn("/api/jam-qr", snapshot["assets"]) + self.assertEqual(snapshot["payloads"]["/api/jam"]["status"], 502) + + def test_png_bounds_crc_dimensions_and_stream(self): + self.assertTrue(publisher.valid_png(png())) + corrupt = bytearray(png()) + corrupt[-1] ^= 1 + for value in (b"", b"\x89PNG\r\n\x1a\n", png() + b"trailing", bytes(corrupt), + png(width=2049), png() + b"x" * publisher.MAX_PNG): + with self.subTest(length=len(value)): + self.assertFalse(publisher.valid_png(value)) + def chunk(kind, data): + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) + header = png()[:33] + for decompressed in (b"\x05\0", b"\0" * 1_000_000): + value = header + chunk(b"IDAT", zlib.compress(decompressed)) + chunk(b"IEND", b"") + self.assertFalse(publisher.valid_png(value)) + + def test_snapshot_size_and_observation_guards(self): + lan = FakeLAN() + lan.bodies["/api/state"]["members"] *= 20_000 + with self.assertRaisesRegex(publisher.PublisherError, "2 MiB"): + publisher.collect_snapshot(lan, lambda: AT) + for current in (AT - 1, AT + 60_001): + with self.assertRaises(publisher.PublisherError): + publisher.collect_snapshot(FakeLAN(), lambda: current) + + +class HTTPTests(unittest.TestCase): + def test_fixed_targets_and_no_arbitrary_url_arguments(self): + for path in ("/api/camera", "/api/state?url=https://evil.example", "http://evil.example/", "//evil.example", "/api/queue"): + with patch.object(publisher.http.client, "HTTPConnection") as connection: + with self.assertRaises(publisher.PublisherError): + publisher.fetch_lan(path) + connection.assert_not_called() + for account, namespace in (("../evil", NAMESPACE), (ACCOUNT, "x" * 32), ("https://evil.example", NAMESPACE)): + with patch.object(publisher.http.client, "HTTPSConnection") as connection: + with self.assertRaises(publisher.PublisherError): + publisher.put_snapshot(account, namespace, TOKEN, b"{}") + connection.assert_not_called() + + def test_lan_never_reads_errors_or_follows_redirects_or_sends_credentials(self): + with patch.object(publisher.http.client, "HTTPConnection") as connection: + response = connection.return_value.getresponse.return_value + response.status = 302 + response.getheader.return_value = "text/html" + result = publisher.fetch_lan("/api/state") + self.assertEqual(result, (302, "text/html", b"")) + response.read.assert_not_called() + connection.assert_called_once_with("192.168.1.112", 8080, timeout=12) + request = connection.return_value.request.call_args + self.assertEqual(request.args, ("GET", "/api/state")) + self.assertNotIn("Authorization", request.kwargs["headers"]) + self.assertNotIn("Cookie", request.kwargs["headers"]) + + def test_https_put_is_single_fixed_nonexpiring_value(self): + with patch.object(publisher.http.client, "HTTPSConnection") as connection: + response = connection.return_value.getresponse.return_value + response.status = 200 + response.read.return_value = b'{"success":true}' + publisher.put_snapshot(ACCOUNT, NAMESPACE, TOKEN, b"{}") + self.assertEqual(connection.call_args.args, ("api.cloudflare.com", 443)) + request = connection.return_value.request.call_args + self.assertEqual(request.args, ("PUT", f"/client/v4/accounts/{ACCOUNT}/storage/kv/namespaces/{NAMESPACE}/values/clubroom:view:v1")) + self.assertEqual(request.kwargs["body"], b"{}") + self.assertEqual(request.kwargs["headers"]["Authorization"], "Bearer " + TOKEN) + self.assertNotIn("?", request.args[1]) + self.assertEqual(connection.return_value.request.call_count, 1) + + def test_cloud_error_body_never_read_or_leaked(self): + for status in (301, 302, 401, 429, 500): + with patch.object(publisher.http.client, "HTTPSConnection") as connection: + response = connection.return_value.getresponse.return_value + response.status = status + response.read.side_effect = AssertionError("error body must not be read") + with self.assertRaises(publisher.PublisherError) as error: + publisher.put_snapshot(ACCOUNT, NAMESPACE, TOKEN, b"{}") + self.assertNotIn(TOKEN, str(error.exception)) + self.assertEqual(connection.return_value.request.call_count, 1) + for body in (b'{"success":false,"errors":["secret"]}', b"not-json", b"x" * (publisher.MAX_REPLY + 1)): + with patch.object(publisher.http.client, "HTTPSConnection") as connection: + response = connection.return_value.getresponse.return_value + response.status, response.read.return_value = 200, body + with self.assertRaises(publisher.PublisherError) as error: + publisher.put_snapshot(ACCOUNT, NAMESPACE, TOKEN, b"{}") + self.assertNotIn("secret", str(error.exception)) + + +class CredentialTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.directory = Path(self.temporary.name) + make_private(self.directory, directory=True) + self.args = arguments(self.directory) + + def tearDown(self): + self.temporary.cleanup() + + def credential_file(self, content): + path = self.directory / "credential" + path.write_text(content, encoding="utf-8") + make_private(path) + return path + + def test_machine_environment_is_explicit_and_private_file_supported(self): + with patch.dict(os.environ, {"SNAPSHOT_TEST_TOKEN": TOKEN}, clear=True): + self.assertEqual(publisher.read_credential(self.args), TOKEN) + self.args.token_env = None + self.args.token_file = str(self.credential_file(TOKEN + "\n")) + self.assertEqual(publisher.read_credential(self.args), TOKEN) + + def test_missing_invalid_or_public_credentials_fail_closed_without_leak(self): + for value in ("", "short", TOKEN + "\r\nInjected: header", "Bearer " + TOKEN): + with patch.dict(os.environ, {"SNAPSHOT_TEST_TOKEN": value}, clear=True): + with self.assertRaises(publisher.PublisherError) as error: + publisher.read_credential(self.args) + self.assertNotIn(TOKEN, str(error.exception)) + if os.name != "nt": + path = self.credential_file(TOKEN) + path.chmod(0o644) + self.args.token_env, self.args.token_file = None, str(path) + with self.assertRaises(publisher.PublisherError): + publisher.read_credential(self.args) + + def test_operator_oauth_is_explicit_private_expiry_checked_mode(self): + self.args.token_env = None + future = datetime.fromtimestamp((AT + 120_000) / 1000, timezone.utc).isoformat() + path = self.credential_file(f'oauth_token = "{TOKEN}"\nexpiration_time = "{future}"\n') + self.args.operator_wrangler_auth_file = str(path) + self.assertEqual(publisher.read_credential(self.args, lambda: AT), TOKEN) + with self.assertRaises(publisher.PublisherError): + publisher.read_credential(self.args, lambda: AT + 120_000) + path.write_text(f'oauth_token = "{TOKEN}"\nexpiration_time = "bad-expiry"\n', encoding="utf-8") + with self.assertRaises(publisher.PublisherError) as error: + publisher.read_credential(self.args, lambda: AT) + self.assertNotIn(TOKEN, str(error.exception)) + + def test_default_plan_performs_no_network_credential_or_checkpoint_io(self): + output = io.StringIO() + with patch.object(publisher, "read_credential", side_effect=AssertionError("credential I/O")), \ + patch.object(publisher, "publish_once", side_effect=AssertionError("publish I/O")), \ + redirect_stdout(output): + self.assertEqual(publisher.main([]), 0) + self.assertEqual(json.loads(output.getvalue())["mode"], "plan") + + def test_cli_error_does_not_print_secret_or_raw_exception(self): + args = ["--once", "--account-id", ACCOUNT, "--namespace-id", NAMESPACE, "--token-env", "SNAPSHOT_TEST_TOKEN", + "--checkpoint-dir", str(self.directory)] + stderr, stdout = io.StringIO(), io.StringIO() + with patch.dict(os.environ, {"SNAPSHOT_TEST_TOKEN": TOKEN + "\nleak"}, clear=True), \ + redirect_stderr(stderr), redirect_stdout(stdout), \ + patch.object(publisher, "fetch_lan", side_effect=AssertionError("network must not be contacted")): + self.assertEqual(publisher.main(args), 1) + self.assertNotIn(TOKEN, stderr.getvalue() + stdout.getvalue()) + with patch.object(publisher, "publish_once", side_effect=OSError(TOKEN)), \ + redirect_stderr(stderr), redirect_stdout(stdout): + self.assertEqual(publisher.main(args), 1) + self.assertNotIn(TOKEN, stderr.getvalue() + stdout.getvalue()) + + +class CheckpointTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.directory = Path(self.temporary.name) + make_private(self.directory, directory=True) + self.args = arguments(self.directory) + self.target = publisher.destination(ACCOUNT, NAMESPACE) + self.identity = hashlib.sha256(self.target.encode("ascii")).hexdigest() + self.checkpoint = self.directory / (self.identity + ".json") + self.environment = patch.dict(os.environ, {"SNAPSHOT_TEST_TOKEN": TOKEN}) + self.environment.start() + + def tearDown(self): + self.environment.stop() + self.temporary.cleanup() + + def load(self, at=AT): + return publisher.load_checkpoint(self.checkpoint, self.target, at) + + def test_restart_keeps_counts_and_cadence_per_destination(self): + uploads = [] + publisher.publish_once(self.args, FakeLAN(), lambda *args: uploads.append(args), lambda: AT) + self.assertEqual(self.load()["attempts"], 1) + self.assertEqual(self.load()["lastPublishedAt"], AT) + with self.assertRaisesRegex(publisher.PublisherError, "cadence"): + publisher.publish_once(self.args, FakeLAN(), lambda *args: uploads.append(args), lambda: AT + 1000) + self.assertEqual(len(uploads), 1) + lan = FakeLAN() + lan.bodies["/api/state"]["now"] = AT + 180_000 + publisher.publish_once(self.args, lan, lambda *args: uploads.append(args), lambda: AT + 180_000) + self.assertEqual(self.load(AT + 180_000)["attempts"], 2) + other = deepcopy(self.args) + other.namespace_id = "c" * 32 + publisher.publish_once(other, FakeLAN(), lambda *args: None, lambda: AT) + self.assertEqual(self.load(AT + 180_000)["attempts"], 2) + self.assertEqual(len(list(self.directory.glob("*.json"))), 2) + + def test_utc_day_budget_guard_and_clock_rollback(self): + midnight = int(datetime(2026, 11, 13, tzinfo=timezone.utc).timestamp() * 1000) + value = publisher.load_checkpoint(self.checkpoint, self.target, midnight - 180_000) + value.update(attempts=600, lastAttemptAt=midnight - 180_000) + publisher.atomic_checkpoint(self.checkpoint, value) + with self.assertRaisesRegex(publisher.PublisherError, "budget"): + publisher.reserve_checkpoint(self.load(midnight - 1), midnight - 1, 180) + reset = self.load(midnight) + self.assertEqual(reset["attempts"], 0) + reserved = publisher.reserve_checkpoint(reset, midnight, 180) + self.assertEqual(reserved["attempts"], 1) + publisher.atomic_checkpoint(self.checkpoint, reserved) + with self.assertRaisesRegex(publisher.PublisherError, "backwards"): + self.load(midnight - 1) + + def test_failed_cloud_write_preserves_success_checkpoint_but_consumes_attempt(self): + publisher.publish_once(self.args, FakeLAN(), lambda *args: None, lambda: AT) + later = AT + 180_000 + lan = FakeLAN() + lan.bodies["/api/state"]["now"] = later + def fail(*args): + raise publisher.PublisherError("Cloudflare KV write unavailable or unconfirmed") + with self.assertRaises(publisher.PublisherError): + publisher.publish_once(self.args, lan, fail, lambda: later) + checkpoint = self.load(later) + self.assertEqual(checkpoint["attempts"], 2) + self.assertEqual(checkpoint["lastAttemptAt"], later) + self.assertEqual(checkpoint["lastPublishedAt"], AT) + + def test_failed_reservation_stops_before_cloud_write(self): + put = unittest.mock.Mock() + with patch.object(publisher, "atomic_checkpoint", side_effect=publisher.PublisherError("atomic checkpoint failed")): + with self.assertRaises(publisher.PublisherError): + publisher.publish_once(self.args, FakeLAN(), put, lambda: AT) + put.assert_not_called() + self.assertFalse(self.checkpoint.exists()) + + def test_corrupt_or_cross_destination_checkpoint_never_resets_budget(self): + for raw in (b"{}", b"not-json", b'{"version":1,"version":1}', b'{"attempts":NaN}'): + self.checkpoint.write_bytes(raw) + make_private(self.checkpoint) + with self.assertRaises(publisher.PublisherError): + self.load() + value = {"version": 1, "destination": self.target + "evil", "day": publisher.utc_day(AT), "attempts": 10, + "lastAttemptAt": AT, "lastPublishedAt": AT} + publisher.atomic_checkpoint(self.checkpoint, value) + with self.assertRaises(publisher.PublisherError): + self.load() + + def test_invalid_checkpoint_dates_and_reservations_fail_closed(self): + valid = {"version": 1, "destination": self.target, "day": publisher.utc_day(AT), "attempts": 1, + "lastAttemptAt": AT, "lastPublishedAt": AT} + for change in ({"day": "2026-99-99"}, {"lastAttemptAt": None}, {"lastPublishedAt": AT + 1}, + {"day": "2020-01-01"}, {"attempts": True}): + publisher.atomic_checkpoint(self.checkpoint, {**valid, **change}) + with self.assertRaises(publisher.PublisherError): + self.load() + + def test_atomic_replace_failure_keeps_existing_checkpoint_and_removes_temporary(self): + value = publisher.load_checkpoint(self.checkpoint, self.target, AT) + publisher.atomic_checkpoint(self.checkpoint, value) + before = self.checkpoint.read_bytes() + with patch.object(publisher.os, "replace", side_effect=OSError("sensitive local detail")): + with self.assertRaisesRegex(publisher.PublisherError, "checkpoint failed"): + publisher.atomic_checkpoint(self.checkpoint, {**value, "attempts": 1}) + self.assertEqual(self.checkpoint.read_bytes(), before) + self.assertEqual(list(self.directory.glob(".snapshot-*")), []) + + def test_invalid_attendance_does_not_reserve_or_write(self): + lan = FakeLAN() + lan.overrides["/api/state"] = (503, "application/json", b"{}") + put = unittest.mock.Mock() + with self.assertRaises(publisher.PublisherError): + publisher.publish_once(self.args, lan, put, lambda: AT) + put.assert_not_called() + self.assertFalse(self.checkpoint.exists()) + + def test_lock_excludes_second_publisher(self): + with publisher.destination_lock(self.directory, self.identity): + with self.assertRaises(publisher.PublisherError): + with publisher.destination_lock(self.directory, self.identity): + self.fail("second publisher acquired lock") + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/attendance/src/dashboard.html b/apps/attendance/src/dashboard.html deleted file mode 100644 index 51fcfa9..0000000 --- a/apps/attendance/src/dashboard.html +++ /dev/null @@ -1,4032 +0,0 @@ - - - - - - - - BCSD 출근부 - - - - -
-
-
-
-

출입 현황

-
- -
- -
- 재생 상태를 확인하고 있습니다 - Spotify Connect -
-
-
-
한국 표준시 · KST
- - - - -
-
- -
-
- 오류 수신 · 24시간 - 확인 중 - Sentry —Cloudflare — - 수신 확인 — - -
-
- Rocky - 확인 중 - CPU —RAM — 실행기 —브로커 — - 호스트 확인 — - 브로커 HTTP·모델 실행 미확인 -
-
- -
- -
-
-
-

관측 시 동아리방에는

-

등록 회원 확인 중

-
-
—명
-
-
-

관측 시 입실한 사람

-

동아리방 현황을 불러오고 있습니다.

-
- -
-
-
-
-

오늘 체류 시간 중앙값

-

—

-
-
-

이번 주 체류 시간 중앙값

-

—

-
-
-

이번 주 퇴근 미기록

-

—

-
-
-
-
-

이번 주의 발자취

- 확인 중 -
-

회원별 기록을 불러오고 있습니다.

- - -
-
-

Wi-Fi 연결

-
-
- -

QR 코드를 불러오는 중

-
BCSD_5G
-
-
- -

QR 코드를 불러오는 중

-
BCSD_2.4G
-
-
-
-
- 첫 데이터를 불러오는 중 -

- 아직 동기화되지 않음 · KV 반영 지연 있음 · 관측 시점 기준 -

-
-
- - -
-

BCSD LAB · 작은 태그 하나로 이어지는 우리의 하루

-

모든 시각은 KST · 관측 시점 데이터 · NFC는 LAN에서 즉시 반영

-
-
-
- - - - -

-

5초 후 자동으로 닫힙니다.

- -
- -
-

오류 수신 · Rocky 관측

- -
-

- Pi 스냅샷과 독립적인 읽기 전용 관측입니다. CPU는 두 Linux 표본의 차이, RAM은 MemTotal − MemAvailable입니다. - 서비스·프로세스 상태는 브로커 HTTP 응답·모델 인증·작업 완료의 증거가 아닙니다. -

-

최근 정상 조회 기록이 없습니다.

-
    -
    - - - - diff --git a/apps/attendance/src/dashboard.ts b/apps/attendance/src/dashboard.ts new file mode 100644 index 0000000..e1c001f --- /dev/null +++ b/apps/attendance/src/dashboard.ts @@ -0,0 +1,706 @@ +import { solarThemeScript } from './sun.ts'; + +/** + * 벽걸이 화면용 단일 HTML입니다. 서버 데이터는 모두 textContent로만 그려 + * 회원 이름 등 사용자 입력이 마크업으로 해석되지 않게 합니다. + */ +export const DASHBOARD_HTML = /* html */ ` + + + + + + +BCSD 출근부 + + + + +
    +
    +
    +
    +

    출입 현황

    +
    +
    +
    한국 표준시 · KST
    + + + + +
    +
    + +
    + +
    +
    +

    지금 동아리방에는

    등록 회원 확인 중

    +
    —명
    +
    +
    +

    지금 있는 사람

    +

    동아리방 현황을 불러오고 있습니다.

    +
    + +
    +
    +
    +

    오늘 중앙값

    —

    +

    이번 주 중앙값

    —

    +

    이번 주 퇴근 미기록

    —

    +
    +
    +

    체류 시간

    확인 중
    +

    회원별 기록을 불러오고 있습니다.

    + + +
    +
    +

    Wi-Fi 연결

    +
    +
    + +

    QR 불러오는 중

    +
    BCSD_5G
    +
    +
    + +

    QR 불러오는 중

    +
    BCSD_2.4G
    +
    +
    +
    +
    첫 데이터를 불러오는 중

    아직 동기화되지 않음 · 10초마다 갱신

    +
    +

    BCSD LAB · 작은 태그 하나로 이어지는 우리의 하루

    모든 시각은 KST · 저장소 반영에 최대 약 60초 소요될 수 있습니다.

    +
    +
    + + + + +`; diff --git a/apps/attendance/src/index.ts b/apps/attendance/src/index.ts index 0f2d05e..dcdf3fa 100644 --- a/apps/attendance/src/index.ts +++ b/apps/attendance/src/index.ts @@ -1,4 +1,4 @@ -import html from './dashboard.html'; +import html from '../.wrangler/viewer/dashboard.html'; import { createViewer } from './viewer.ts'; export default createViewer(html); diff --git a/apps/attendance/src/internal-auth.ts b/apps/attendance/src/internal-auth.ts new file mode 100644 index 0000000..6612b91 --- /dev/null +++ b/apps/attendance/src/internal-auth.ts @@ -0,0 +1,352 @@ +export interface InternalAuthEnv { + INTERNAL_AUTH_CLIENT_ID?: string; + INTERNAL_AUTH_CLIENT_SECRET?: string; +} + +export interface VerifiedInternalSession { + memberId: string; + name: string; + expiresAt: number; +} + +export type InternalSessionResult = { ok: true; session: VerifiedInternalSession } | { ok: false; response: Response }; + +export interface InternalAuthOptions { + fetch?: typeof fetch; + now?: () => number; +} + +export interface InternalAuth { + verify(request: Request): Promise; + login(request: Request): Promise; + me(request: Request): Promise; + logout(request: Request): Promise; +} + +export const INTERNAL_SESSION_COOKIE = '__Host-attendance-session'; +const LOGIN_URL = 'https://bcsd-attendance-auth.koreatech.in/v1/auth/attendance-login'; +const SESSION_URL = 'https://bcsd-attendance-auth.koreatech.in/v1/members/attendance-session'; +const DEADLINE_MS = 5_000; +const SESSION_MS = 900_000; +const MAX_REQUEST_BYTES = 4_096; +const MAX_RESPONSE_BYTES = 8_192; +const MAX_TOKEN_LENGTH = 3_500; +const COOKIE_ATTRIBUTES = 'Path=/; Secure; HttpOnly; SameSite=Strict'; +const JSON_TYPE = /^application\/json(?:\s*;\s*charset\s*=\s*(?:utf-8|"utf-8"))?\s*$/i; + +function object(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function keys(value: Record, expected: string[]): boolean { + return Object.keys(value).length === expected.length && expected.every((key) => Object.hasOwn(value, key)); +} + +function hasControlCharacters(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code < 0x20 || code === 0x7f) return true; + } + return false; +} + +function validToken(value: unknown): value is string { + // 문법만 제한하며 서명·회원 자격·비밀번호 버전은 Internal에서 매 요청 검증한다. + return ( + typeof value === 'string' && + value.length <= MAX_TOKEN_LENGTH && + /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value) + ); +} + +function configured(env: InternalAuthEnv): boolean { + return [env.INTERNAL_AUTH_CLIENT_ID, env.INTERNAL_AUTH_CLIENT_SECRET].every( + (value) => typeof value === 'string' && /^[\x21-\x7e]{1,1024}$/.test(value), + ); +} + +function clearCookie(): string { + return `${INTERNAL_SESSION_COOKIE}=; ${COOKIE_ATTRIBUTES}; Max-Age=0`; +} + +function reply(body: unknown, status = 200, cookie?: string, allow?: string): Response { + const headers = new Headers({ + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'private, no-store', + 'x-content-type-options': 'nosniff', + 'referrer-policy': 'no-referrer', + }); + if (cookie) headers.set('set-cookie', cookie); + if (allow) headers.set('allow', allow); + return new Response(JSON.stringify(body), { status, headers }); +} + +function failure(status: number, clear = false, allow?: string): Response { + const error = + status === 401 + ? 'authentication_required' + : status === 503 + ? 'authentication_unavailable' + : status === 403 + ? 'same_origin_required' + : status === 405 + ? 'method_not_allowed' + : 'invalid_request'; + return reply({ error }, status, clear ? clearCookie() : undefined, allow); +} + +function cookieToken(request: Request): string | null | false { + const raw = request.headers.get('cookie'); + if (!raw) return null; + if (raw.length > 8_192 || raw.includes(',') || hasControlCharacters(raw)) return false; + let token: string | null = null; + for (const part of raw.split(';')) { + const separator = part.indexOf('='); + const name = (separator < 0 ? part : part.slice(0, separator)).trim(); + if (name !== INTERNAL_SESSION_COOKIE) continue; + if (token !== null || separator < 0) return false; + const value = part.slice(separator + 1); + if (!validToken(value)) return false; + token = value; + } + return token; +} + +function discard(body: ReadableStream | null): void { + void body?.cancel().catch(() => {}); +} + +// 타이머는 본문의 마지막 바이트까지 유효하며 연결 종료 시 상류와 본문 리더를 중단한다. +async function bounded(task: (signal: AbortSignal) => Promise, parent?: AbortSignal): Promise { + const controller = new AbortController(); + const { promise, reject } = Promise.withResolvers(); + const abort = () => { + controller.abort(); + reject(new Error()); + }; + const timer = setTimeout(abort, DEADLINE_MS); + parent?.addEventListener('abort', abort, { once: true }); + try { + if (parent?.aborted) abort(); + return await Promise.race([promise, task(controller.signal)]); + } finally { + clearTimeout(timer); + parent?.removeEventListener('abort', abort); + controller.abort(); + } +} + +async function readJson(message: Request | Response, maximum: number, signal: AbortSignal): Promise { + if (!JSON_TYPE.test(message.headers.get('content-type') ?? '')) throw new Error(); + const encoding = message.headers.get('content-encoding'); + if (encoding && encoding.toLowerCase() !== 'identity') throw new Error(); + const length = message.headers.get('content-length'); + if (length !== null && (!/^\d+$/.test(length) || Number(length) > maximum)) throw new Error(); + if (!message.body || signal.aborted) throw new Error(); + const reader = message.body.getReader(); + const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); + let bytes = 0; + let text = ''; + let complete = false; + const cancel = () => { + void reader.cancel().catch(() => {}); + }; + signal.addEventListener('abort', cancel, { once: true }); + try { + while (true) { + if (signal.aborted) throw new Error(); + const { done, value } = await reader.read(); + if (signal.aborted) throw new Error(); + if (done) break; + bytes += value.byteLength; + if (bytes > maximum) throw new Error(); + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + const result: unknown = JSON.parse(text); + complete = true; + return result; + } finally { + signal.removeEventListener('abort', cancel); + if (!complete) cancel(); + reader.releaseLock(); + } +} + +type UpstreamResult = { status: 200; value: unknown } | { status: 401 | 503 }; + +async function upstream( + env: InternalAuthEnv, + transport: typeof fetch, + target: typeof LOGIN_URL | typeof SESSION_URL, + signal: AbortSignal, + credentials: { studentNumber: string; password: string } | { token: string }, +): Promise { + if (signal.aborted) return { status: 503 }; + const headers = new Headers({ + accept: 'application/json', + 'CF-Access-Client-Id': env.INTERNAL_AUTH_CLIENT_ID!, + 'CF-Access-Client-Secret': env.INTERNAL_AUTH_CLIENT_SECRET!, + }); + let body: string | undefined; + if ('password' in credentials) { + headers.set('content-type', 'application/json; charset=utf-8'); + body = JSON.stringify(credentials); + } else { + headers.set('authorization', `Bearer ${credentials.token}`); + } + const response = await transport(target, { + method: target === LOGIN_URL ? 'POST' : 'GET', + headers, + body, + redirect: 'manual', + cache: 'no-store', + signal, + }); + if (signal.aborted || response.redirected || (response.url && response.url !== target)) { + discard(response.body); + return { status: 503 }; + } + if (response.status !== 200) { + discard(response.body); + return { status: response.status === 401 ? 401 : 503 }; + } + try { + return { status: 200, value: await readJson(response, MAX_RESPONSE_BYTES, signal) }; + } catch { + discard(response.body); + return { status: 503 }; + } +} + +function session(value: unknown, now: number): VerifiedInternalSession | null { + if (!object(value) || !keys(value, ['memberId', 'name', 'expiresAt'])) return null; + if (typeof value.memberId !== 'string' || !/^[1-9]\d{0,18}$/.test(value.memberId)) return null; + if ( + typeof value.name !== 'string' || + !value.name.trim() || + value.name.length > 200 || + hasControlCharacters(value.name) + ) + return null; + if ( + typeof value.expiresAt !== 'number' || + !Number.isSafeInteger(value.expiresAt) || + value.expiresAt <= now || + value.expiresAt > now + SESSION_MS + ) + return null; + return { memberId: value.memberId, name: value.name, expiresAt: value.expiresAt }; +} + +function mutationGuard(request: Request): Response | null { + if (request.method !== 'POST') return failure(405, false, 'POST'); + const url = new URL(request.url); + const site = request.headers.get('sec-fetch-site'); + if ( + url.protocol !== 'https:' || + request.headers.get('origin') !== url.origin || + (site !== null && site !== 'same-origin') + ) + return failure(403); + if (url.search || url.hash || !JSON_TYPE.test(request.headers.get('content-type') ?? '')) return failure(400); + return null; +} + +/** 반드시 CF_ACCESS_AUD의 플랫폼 ctx 게이트 뒤에서 호출하고 저장소·서비스 조회보다 앞에 둔다. */ +export function createInternalAuth(env: InternalAuthEnv, options: InternalAuthOptions = {}): InternalAuth { + const transport = options.fetch ?? globalThis.fetch; + const now = options.now ?? Date.now; + + async function verify(request: Request): Promise { + if (!configured(env)) return { ok: false, response: failure(503) }; + const token = cookieToken(request); + if (!token) return { ok: false, response: failure(401, token === false) }; + try { + const result = await bounded( + (signal) => upstream(env, transport, SESSION_URL, signal, { token }), + request.signal, + ); + if (result.status !== 200) return { ok: false, response: failure(result.status, result.status === 401) }; + const verified = session(result.value, now()); + if (!verified) return { ok: false, response: failure(503) }; + return { ok: true, session: verified }; + } catch { + return { ok: false, response: failure(503) }; + } + } + + return { + verify, + async login(request: Request): Promise { + const rejected = mutationGuard(request); + if (rejected) return rejected; + if (!configured(env)) return failure(503); + if (cookieToken(request) === false) return failure(401, true); + let credentials: { studentNumber: string; password: string }; + try { + const value = await bounded((signal) => readJson(request, MAX_REQUEST_BYTES, signal), request.signal); + if ( + !object(value) || + !keys(value, ['studentNumber', 'password']) || + typeof value.studentNumber !== 'string' || + !/^\d{8,10}$/.test(value.studentNumber) || + typeof value.password !== 'string' || + !value.password.trim() + ) + return failure(400); + credentials = { studentNumber: value.studentNumber, password: value.password }; + } catch { + return failure(400); + } + const startedAt = now(); + try { + return await bounded(async (signal) => { + const login = await upstream(env, transport, LOGIN_URL, signal, credentials); + if (login.status !== 200) return failure(login.status, login.status === 401); + const value = login.value; + if ( + !object(value) || + !keys(value, ['accessToken', 'tokenType', 'expiresIn']) || + !validToken(value.accessToken) || + value.tokenType !== 'Bearer' || + value.expiresIn !== 900 + ) + return failure(503); + const result = await upstream(env, transport, SESSION_URL, signal, { token: value.accessToken }); + if (result.status !== 200) return failure(result.status, result.status === 401); + const verified = session(result.value, now()); + if (!verified) return failure(503); + verified.expiresAt = Math.min(verified.expiresAt, startedAt + SESSION_MS); + const seconds = Math.floor((verified.expiresAt - now()) / 1_000); + if (seconds <= 0 || signal.aborted) return failure(401, true); + return reply( + { authenticated: true, ...verified, servedAt: now() }, + 200, + `${INTERNAL_SESSION_COOKIE}=${value.accessToken}; ${COOKIE_ATTRIBUTES}; Max-Age=${seconds}`, + ); + }, request.signal); + } catch { + return failure(503); + } + }, + async me(request: Request): Promise { + if (request.method !== 'GET') return failure(405, false, 'GET'); + const result = await verify(request); + return result.ok ? reply({ authenticated: true, ...result.session, servedAt: now() }) : result.response; + }, + async logout(request: Request): Promise { + const rejected = mutationGuard(request); + if (rejected) return rejected; + try { + const value = await bounded((signal) => readJson(request, MAX_REQUEST_BYTES, signal), request.signal); + if (!object(value) || Object.keys(value).length !== 0) return failure(400); + } catch { + return failure(400); + } + if (cookieToken(request) === false) return failure(401, true); + // 이 앱 쿠키만 지우며 Internal 로그아웃·출결 쓰기는 호출하지 않는다. + return reply({ authenticated: false, servedAt: now() }, 200, clearCookie()); + }, + }; +} diff --git a/apps/attendance/src/sun.ts b/apps/attendance/src/sun.ts new file mode 100644 index 0000000..390fce7 --- /dev/null +++ b/apps/attendance/src/sun.ts @@ -0,0 +1,57 @@ +/** Browser-side NOAA sunrise/sunset calculation for Byeongcheon-myeon. */ +export const solarThemeScript: string = ` +function isByeongcheonDay(now) { + const latitude = 36.76; + const longitude = 127.30; + const radians = Math.PI / 180; + const degrees = 180 / Math.PI; + const dayMs = 86400000; + + // Get the Korean calendar date without depending on the viewer's timezone. + const koreanDate = new Date(now.getTime() + 9 * 60 * 60 * 1000); + const midnightUtc = Date.UTC(koreanDate.getUTCFullYear(), koreanDate.getUTCMonth(), koreanDate.getUTCDate()); + const julianDay = midnightUtc / dayMs + 2440587.5; + + function solarPosition(jd) { + const t = (jd - 2451545) / 36525; + const meanLongitude = ((280.46646 + t * (36000.76983 + t * 0.0003032)) % 360 + 360) % 360; + const meanAnomaly = 357.52911 + t * (35999.05029 - 0.0001537 * t); + const eccentricity = 0.016708634 - t * (0.000042037 + 0.0000001267 * t); + const center = Math.sin(meanAnomaly * radians) * (1.914602 - t * (0.004817 + 0.000014 * t)) + + Math.sin(2 * meanAnomaly * radians) * (0.019993 - 0.000101 * t) + + Math.sin(3 * meanAnomaly * radians) * 0.000289; + const omega = 125.04 - 1934.136 * t; + const apparentLongitude = meanLongitude + center - 0.00569 - 0.00478 * Math.sin(omega * radians); + const obliquity = 23 + (26 + (21.448 - t * (46.815 + t * (0.00059 - t * 0.001813))) / 60) / 60 + + 0.00256 * Math.cos(omega * radians); + const declination = Math.asin(Math.sin(obliquity * radians) * Math.sin(apparentLongitude * radians)); + const y = Math.tan(obliquity * radians / 2) ** 2; + const equationOfTime = 4 * degrees * (y * Math.sin(2 * meanLongitude * radians) + - 2 * eccentricity * Math.sin(meanAnomaly * radians) + + 4 * eccentricity * y * Math.sin(meanAnomaly * radians) * Math.cos(2 * meanLongitude * radians) + - 0.5 * y * y * Math.sin(4 * meanLongitude * radians) + - 1.25 * eccentricity * eccentricity * Math.sin(2 * meanAnomaly * radians)); + return { declination, equationOfTime }; + } + + // The 90.833-degree zenith accounts for atmospheric refraction and the solar disc. + function solarEvent(isSunrise) { + let minutes = 720 - 4 * longitude; + for (let i = 0; i < 3; i++) { + const position = solarPosition(julianDay + minutes / 1440); + const cosineHourAngle = (Math.cos(90.833 * radians) - Math.sin(latitude * radians) * Math.sin(position.declination)) + / (Math.cos(latitude * radians) * Math.cos(position.declination)); + // These cases cannot occur at Byeongcheon, but avoid an invalid acos at the limits. + if (cosineHourAngle > 1) return null; + if (cosineHourAngle < -1) return null; + const hourAngle = Math.acos(cosineHourAngle) * degrees; + minutes = 720 - 4 * (longitude + (isSunrise ? hourAngle : -hourAngle)) - position.equationOfTime; + } + return midnightUtc + minutes * 60000; + } + + const sunrise = solarEvent(true); + const sunset = solarEvent(false); + return sunrise !== null && sunset !== null && now.getTime() >= sunrise && now.getTime() < sunset; +} +`; diff --git a/apps/attendance/src/viewer.ts b/apps/attendance/src/viewer.ts index 6410767..5dd56c4 100644 --- a/apps/attendance/src/viewer.ts +++ b/apps/attendance/src/viewer.ts @@ -1,6 +1,13 @@ import { handleAutomation, type AutomationEnv } from './automation.ts'; +import { + createInternalAuth, + type InternalAuth, + type InternalAuthEnv, + type InternalAuthOptions, +} from './internal-auth.ts'; export type Env = AutomationEnv & + InternalAuthEnv & Partial> & { CF_ACCESS_AUD?: string; }; @@ -41,7 +48,7 @@ const READ_PATHS: Record = { '/api/jam-qr': true, }; const CSP = - "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data: https://i.scdn.co https://mosaic.scdn.co; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data: blob: https://i.scdn.co https://mosaic.scdn.co; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; const encoder = new TextEncoder(); const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }); @@ -190,7 +197,7 @@ function failure(status: number, error: string, snapshot?: Snapshot, allow?: str return new Response(JSON.stringify({ error, mode: 'hosted_snapshot' }), { status, headers: responseHeaders }); } -function events(request: Request, env: Env, initial: Snapshot, deadline: number): Response { +function events(request: Request, env: Env, initial: Snapshot, deadline: number, auth: InternalAuth): Response { let ended = false; let poll: ReturnType; let lifetime: ReturnType; @@ -211,7 +218,20 @@ function events(request: Request, env: Env, initial: Snapshot, deadline: number) const emit = (event: string, body: unknown) => output.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(body)}\n\n`)); const check = async () => { + if (ended || Date.now() >= deadline) { + stop(true); + return; + } + const verification = await auth.verify(request); if (ended) return; + if (!verification.ok || Date.now() >= deadline || Date.now() >= verification.session.expiresAt) { + stop(true); + return; + } + // 갱신된 자격의 만료 시각도 기존 스트림 수명을 늘리지 못한다. + deadline = Math.min(deadline, verification.session.expiresAt); + clearTimeout(lifetime); + lifetime = setTimeout(() => stop(true), Math.max(0, deadline - Date.now())); const result = await loadSnapshot(env); if (ended) return; if (Date.now() >= deadline) { @@ -238,7 +258,7 @@ function events(request: Request, env: Env, initial: Snapshot, deadline: number) start(controller) { output = controller; request.signal.addEventListener('abort', clientLeft, { once: true }); - if (request.signal.aborted) { + if (request.signal.aborted || Date.now() >= deadline) { stop(true); return; } @@ -258,16 +278,16 @@ function events(request: Request, env: Env, initial: Snapshot, deadline: number) } /** 기존 LAN 템플릿으로 빌드한 HTML이며 Worker Static Assets는 사용하지 않는다. */ -export function createViewer(html: string) { +export function createViewer(html: string, authOptions: InternalAuthOptions = {}) { return { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { const deadline = Date.now() + STREAM_MS; const url = new URL(request.url); - // 카메라·관리자·인증·쓰기 경로는 원격으로 중계하지 않는다. - if (!Object.hasOwn(READ_PATHS, url.pathname)) return failure(404, 'not_found'); - if (request.method !== 'GET' && !(url.pathname === '/' && request.method === 'HEAD')) { - return failure(405, 'method_not_allowed', undefined, url.pathname === '/' ? 'GET, HEAD' : 'GET'); - } + // 원격 인증은 두 경로의 전용 브리지로만 수행하고 관리·출결 쓰기는 노출하지 않는다. + const authRoute = url.pathname === '/api/login' || url.pathname === '/api/me' || url.pathname === '/api/logout'; + if (!authRoute && !Object.hasOwn(READ_PATHS, url.pathname)) return failure(404, 'not_found'); + const allowed = url.pathname === '/' ? 'GET, HEAD' : authRoute && url.pathname !== '/api/me' ? 'POST' : 'GET'; + if (!allowed.split(', ').includes(request.method)) return failure(405, 'method_not_allowed', undefined, allowed); if (request.url.includes('?') || url.hash) return failure(400, 'query_not_allowed'); if ( typeof env.CF_ACCESS_AUD !== 'string' || @@ -280,24 +300,45 @@ export function createViewer(html: string) { // 플랫폼 인증만 신뢰하고 클라이언트 헤더·쿠키를 권한으로 해석하지 않는다. if (!ctx?.access || ctx.access.aud !== env.CF_ACCESS_AUD) return failure(403, 'access_required'); if (request.signal.aborted) return failure(499, 'client_closed'); - if (url.pathname === '/api/automation') { - const response = await handleAutomation(env); + const auth = createInternalAuth(env, authOptions); + if (authRoute) { + const response = await (url.pathname === '/api/login' + ? auth.login(request) + : url.pathname === '/api/logout' + ? auth.logout(request) + : auth.me(request)); for (const [name, value] of headers('application/json; charset=utf-8')) response.headers.set(name, value); - response.headers.set('x-bcsd-view-mode', 'live-automation-read-only'); return response; } if (url.pathname === '/') { return new Response(request.method === 'HEAD' ? null : html, { headers: headers('text/html; charset=utf-8') }); } + const verification = await auth.verify(request); + if (!verification.ok) { + for (const [name, value] of headers('application/json; charset=utf-8')) + verification.response.headers.set(name, value); + return verification.response; + } + if (Date.now() >= verification.session.expiresAt) return failure(401, 'authentication_required'); + if (url.pathname === '/api/automation') { + const response = await handleAutomation(env); + if (Date.now() >= verification.session.expiresAt) return failure(401, 'authentication_required'); + for (const [name, value] of headers('application/json; charset=utf-8')) response.headers.set(name, value); + response.headers.set('x-bcsd-view-mode', 'live-automation-read-only'); + return response; + } const result = await loadSnapshot(env); + if (Date.now() >= verification.session.expiresAt) return failure(401, 'authentication_required'); if (url.pathname === '/api/view-status') { const snapshot = result.snapshot; + const servedAt = Date.now(); return new Response( JSON.stringify({ available: !result.error, observedAt: snapshot?.observedAt ?? null, publishedAt: snapshot?.publishedAt ?? null, - ageMs: snapshot ? Date.now() - snapshot.observedAt : null, + servedAt, + ageMs: snapshot ? servedAt - snapshot.observedAt : null, }), { status: result.error ? 503 : 200, @@ -309,7 +350,7 @@ export function createViewer(html: string) { const snapshot = result.snapshot; if (url.pathname === '/api/events') { if (Date.now() >= deadline) return failure(503, 'snapshot_unavailable'); - return events(request, env, snapshot, deadline); + return events(request, env, snapshot, Math.min(deadline, verification.session.expiresAt), auth); } if (url.pathname === '/health') { return new Response( diff --git a/apps/attendance/test/automation.test.mjs b/apps/attendance/test/automation.test.mjs index 92e7aba..c9a3aa1 100644 --- a/apps/attendance/test/automation.test.mjs +++ b/apps/attendance/test/automation.test.mjs @@ -3,7 +3,13 @@ import test from 'node:test'; import { createViewer } from '../src/viewer.ts'; import { getAutomationResult, handleAutomation } from '../src/automation.ts'; -const worker = createViewer('
    private viewer
    '); +const worker = createViewer('
    private viewer
    ', { + fetch: async (url, init) => { + assert.equal(url, 'https://bcsd-attendance-auth.koreatech.in/v1/members/attendance-session'); + assert.equal(new Headers(init.headers).get('authorization'), 'Bearer a.b.c'); + return Response.json({ memberId: '42', name: '홍길동', expiresAt: Date.now() + 899_000 }); + }, +}); const context = { access: { aud: 'test-private-audience' } }; const NOW = Date.UTC(2026, 10, 13, 12); @@ -27,6 +33,8 @@ const json = (value) => Response.json(value); const binding = (fetch) => ({ fetch }); const env = (fetch) => ({ CF_ACCESS_AUD: 'test-private-audience', + INTERNAL_AUTH_CLIENT_ID: 'isolated-monitor-client', + INTERNAL_AUTH_CLIENT_SECRET: 'isolated-monitor-secret', ERROR_AUTOMATION: binding(fetch), ERROR_MONITOR_TOKEN: TOKEN, DASHBOARD_PASSWORD: 'secret', @@ -41,7 +49,9 @@ const env = (fetch) => ({ ), }); const request = (headers = {}, path = '/api/automation') => - new Request('https://attendance.example' + path, { headers }); + new Request('https://attendance.example' + path, { + headers: { cookie: '__Host-attendance-session=a.b.c', ...headers }, + }); test('platform Access context, not client headers, authorizes the independent monitor route', async (t) => { t.mock.method(Date, 'now', () => NOW); @@ -77,10 +87,63 @@ test('platform Access context, not client headers, authorizes the independent mo assert.equal(response.status, 200); assert.equal(response.headers.get('cache-control'), 'private, no-store'); assert.equal(response.headers.get('x-bcsd-view-mode'), 'live-automation-read-only'); - assert.equal((await response.json()).state, 'ok'); + const result = await response.json(); + assert.equal(result.state, 'ok'); + assert.equal(result.servedAt, NOW); assert.equal(calls, 2); }); +test('live monitor reads remain independent of absent, missing, stale, malformed, or unavailable Pi snapshots', async (t) => { + t.mock.method(Date, 'now', () => NOW); + const stale = { + version: 1, + observedAt: NOW - 300_001, + publishedAt: NOW, + payloads: Object.fromEntries( + ['/api/state', '/api/monitor', '/api/koin-health', '/api/worker-health', '/api/spotify', '/api/jam'].map( + (path) => [path, { status: 200, body: {} }], + ), + ), + assets: { + '/api/wifi-qr/2.4': + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6t8sAAAAASUVORK5CYII=', + '/api/wifi-qr/5': 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6t8sAAAAASUVORK5CYII=', + }, + }; + let reads = 0; + const namespace = (value) => ({ + async get() { + reads++; + if (value instanceof Error) throw value; + return value === null ? null : new TextEncoder().encode(JSON.stringify(value)).buffer; + }, + }); + for (const VIEW_STATE of [ + undefined, + namespace(null), + namespace(stale), + namespace({ ...stale, payloads: {} }), + namespace(new Error('private-kv-detail')), + ]) { + let calls = 0; + const workerEnv = { + ...env(async (url) => { + calls++; + return json(url.endsWith('/rocky') ? rockySnapshot() : snapshot()); + }), + VIEW_STATE, + }; + const response = await worker.fetch(request(), workerEnv, context); + assert.equal(response.status, 200); + const result = await response.json(); + assert.equal(result.state, 'ok'); + assert.equal(result.rocky.state, 'ok'); + assert.equal(result.servedAt, NOW); + assert.equal(calls, 2); + } + assert.equal(reads, 0); +}); + test('fixed service binding, HTTPS path, private token, and no redirect follow', async () => { let calls = 0; const result = await getAutomationResult( @@ -260,6 +323,7 @@ test('incoming issues and Rocky health fail independently without fabricating co assert.equal(response.status, 200); assert.equal(result.state, failed === 'ingress' ? 'unknown' : 'ok'); assert.equal(result.rocky.state, failed === 'rocky' ? 'unknown' : 'ok'); + assert.equal(result.servedAt, NOW); if (failed === 'ingress') { assert.equal(result.data, null); assert.equal(result.rocky.data.cpu.usagePercent, 12.5); diff --git a/apps/attendance/test/internal-auth.test.mjs b/apps/attendance/test/internal-auth.test.mjs new file mode 100644 index 0000000..ea4f4a9 --- /dev/null +++ b/apps/attendance/test/internal-auth.test.mjs @@ -0,0 +1,579 @@ +import assert from 'node:assert/strict'; +import test, { before, after, mock } from 'node:test'; +import { createInternalAuth, INTERNAL_SESSION_COOKIE } from '../src/internal-auth.ts'; + +const NOW = 1_800_000_000_000; +const TOKEN = 'eyJhbGciOiJIUzI1NiJ9.eyJzY29wZSI6IkFUVEVOREFOQ0UifQ.c2lnbmF0dXJl'; +const LOGIN_URL = 'https://bcsd-attendance-auth.koreatech.in/v1/auth/attendance-login'; +const SESSION_URL = 'https://bcsd-attendance-auth.koreatech.in/v1/members/attendance-session'; +const ENV = { + INTERNAL_AUTH_CLIENT_ID: 'isolated-client.access', + INTERNAL_AUTH_CLIENT_SECRET: 'isolated-service-secret', +}; +const CREDENTIALS = { studentNumber: '2023123456', password: ' isolated-password ' }; +const SESSION = { memberId: '42', name: '홍길동', expiresAt: NOW + 899_000 }; +const COOKIE = `${INTERNAL_SESSION_COOKIE}=${TOKEN}`; + +before(() => { + mock.method(globalThis, 'fetch', () => assert.fail('Unexpected real network request')); +}); +after(() => mock.restoreAll()); + +function request(path = '/api/me', options = {}) { + return new Request(`https://attendance.example${path}`, { + ...options, + headers: { cookie: COOKIE, ...options.headers }, + }); +} + +function mutation(path = '/api/login', value = CREDENTIALS, headers = {}) { + return request(path, { + method: 'POST', + headers: { origin: 'https://attendance.example', 'content-type': 'application/json', ...headers }, + body: JSON.stringify(value), + }); +} + +function fixture(responses = [], overrides = {}) { + const calls = []; + const transport = async (url, init) => { + calls.push({ url, ...init, headers: new Headers(init.headers) }); + assert.ok(responses.length, 'Unexpected upstream request'); + const next = responses.shift(); + return typeof next === 'function' ? next(url, init) : next; + }; + const auth = createInternalAuth({ ...ENV, ...overrides }, { fetch: transport, now: () => NOW }); + return { auth, calls }; +} + +function loginPayload(change = {}) { + return Response.json({ accessToken: TOKEN, tokenType: 'Bearer', expiresIn: 900, ...change }); +} + +async function assertSafeFailure(response, status) { + assert.equal(response.status, status); + assert.equal(response.headers.get('cache-control'), 'private, no-store'); + assert.equal(response.headers.get('x-content-type-options'), 'nosniff'); + const text = await response.text(); + for (const secret of [TOKEN, CREDENTIALS.password.trim(), ENV.INTERNAL_AUTH_CLIENT_SECRET, 'upstream-diagnostic']) { + assert.equal(text.includes(secret), false); + } + assert.deepEqual(Object.keys(JSON.parse(text)), ['error']); +} + +function cookieHeader(response) { + const header = response.headers.get('set-cookie'); + assert.ok(header); + assert.match(header, /^__Host-attendance-session=/); + for (const attribute of ['Path=/', 'Secure', 'HttpOnly', 'SameSite=Strict']) assert.ok(header.includes(attribute)); + assert.doesNotMatch(header, /Domain=/i); + return header; +} + +test('login uses only the two fixed broker paths and returns safe live metadata with an HttpOnly cookie', async () => { + const { auth, calls } = fixture([loginPayload(), Response.json({ ...SESSION })]); + const response = await auth.login( + mutation('/api/login', CREDENTIALS, { + authorization: 'Basic attacker', + 'cf-access-client-id': 'attacker-id', + 'cf-access-client-secret': 'attacker-secret', + 'x-forwarded-host': 'attacker.example', + }), + ); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { authenticated: true, ...SESSION, servedAt: NOW }); + assert.equal(cookieHeader(response), `${COOKIE}; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=899`); + assert.equal(calls.length, 2); + assert.equal(calls[0].url, LOGIN_URL); + assert.equal(calls[1].url, SESSION_URL); + assert.equal(calls[0].method, 'POST'); + assert.equal(calls[1].method, 'GET'); + assert.deepEqual(JSON.parse(calls[0].body), CREDENTIALS); + assert.equal(calls[1].body, undefined); + for (const call of calls) { + assert.equal(call.redirect, 'manual'); + assert.equal(call.cache, 'no-store'); + assert.equal(call.headers.get('cf-access-client-id'), ENV.INTERNAL_AUTH_CLIENT_ID); + assert.equal(call.headers.get('cf-access-client-secret'), ENV.INTERNAL_AUTH_CLIENT_SECRET); + assert.equal(call.headers.get('cookie'), null); + assert.equal(call.headers.get('x-forwarded-host'), null); + assert.equal(call.headers.get('accept'), 'application/json'); + } + assert.equal(calls[0].headers.get('authorization'), null); + assert.equal(calls[1].headers.get('authorization'), `Bearer ${TOKEN}`); +}); + +test('login never creates a browser session until the new token passes the real session endpoint', async () => { + for (const status of [401, 403, 500, 302]) { + const { auth, calls } = fixture([loginPayload(), new Response('upstream-diagnostic', { status })]); + const response = await auth.login(mutation()); + await assertSafeFailure(response, status === 401 ? 401 : 503); + assert.equal(calls.length, 2); + assert.equal(response.headers.get('set-cookie')?.includes(TOKEN) ?? false, false); + } +}); + +test('missing or malformed dedicated machine secrets fail closed without any fallback', async () => { + for (const key of Object.keys(ENV)) { + for (const value of [undefined, '', ' whitespace ', 'bad\r\nheader', 'x'.repeat(1025)]) { + const { auth, calls } = fixture([], { [key]: value, BASIC_AUTH_PASSWORD: 'legacy', JWT_SECRET: 'legacy' }); + await assertSafeFailure(await auth.login(mutation()), 503); + await assertSafeFailure(await auth.me(request()), 503); + assert.equal(calls.length, 0); + } + } +}); + +test('bad credentials remain 401 while broker, rate-limit and network failures are generic 503', async () => { + for (const status of [401, 400, 403, 429, 500, 503]) { + const { auth, calls } = fixture([new Response('upstream-diagnostic', { status })]); + await assertSafeFailure(await auth.login(mutation()), status === 401 ? 401 : 503); + assert.equal(calls.length, 1); + } + const { auth } = fixture([ + () => { + throw new Error('upstream-diagnostic'); + }, + ]); + await assertSafeFailure(await auth.login(mutation()), 503); +}); + +test('login and logout require exact HTTPS same-Origin JSON before network or cookie changes', async () => { + for (const method of ['login', 'logout']) { + for (const headers of [ + { origin: '' }, + { origin: 'null' }, + { origin: 'https://attacker.example' }, + { origin: 'https://attendance.example/' }, + { 'sec-fetch-site': 'cross-site' }, + { 'content-type': 'text/plain' }, + { 'content-type': 'application/json; charset=iso-8859-1' }, + ]) { + const { auth, calls } = fixture(); + const response = await auth[method](mutation(`/api/${method}`, method === 'logout' ? {} : CREDENTIALS, headers)); + assert.ok([400, 403].includes(response.status)); + assert.equal(response.headers.get('set-cookie'), null); + assert.equal(calls.length, 0); + } + const { auth, calls } = fixture(); + const response = await auth[method]( + new Request(`http://attendance.example/api/${method}`, { + method: 'POST', + headers: { origin: 'http://attendance.example', 'content-type': 'application/json' }, + body: '{}', + }), + ); + await assertSafeFailure(response, 403); + assert.equal(calls.length, 0); + } +}); + +test('method guards do not authenticate or mutate cookies', async () => { + const { auth, calls } = fixture(); + for (const [handler, method, allow] of [ + ['login', 'GET', 'POST'], + ['logout', 'GET', 'POST'], + ['me', 'POST', 'GET'], + ]) { + const response = await auth[handler](request(`/api/${handler}`, { method })); + await assertSafeFailure(response, 405); + assert.equal(response.headers.get('allow'), allow); + assert.equal(response.headers.get('set-cookie'), null); + } + assert.equal(calls.length, 0); +}); + +test('invalid input, extra forwarding fields, invalid UTF-8 and byte bounds never reach Internal', async () => { + const bodies = [ + {}, + [], + null, + { ...CREDENTIALS, studentNumber: '1234567' }, + { ...CREDENTIALS, studentNumber: '12345678901' }, + { ...CREDENTIALS, studentNumber: 2023123456 }, + { ...CREDENTIALS, password: '' }, + { ...CREDENTIALS, password: ' \n ' }, + { ...CREDENTIALS, password: 123 }, + { ...CREDENTIALS, target: 'https://attacker.example' }, + ].map((value) => JSON.stringify(value)); + bodies.push( + '{', + 'x'.repeat(4097), + JSON.stringify({ ...CREDENTIALS, password: '가'.repeat(1400) }), + new Uint8Array([0xc3, 0x28]), + ); + for (const body of bodies) { + const { auth, calls } = fixture(); + const response = await auth.login( + request('/api/login', { + method: 'POST', + headers: { origin: 'https://attendance.example', 'content-type': 'application/json' }, + body, + }), + ); + await assertSafeFailure(response, 400); + assert.equal(calls.length, 0); + } + for (const headers of [ + { 'content-length': '4097' }, + { 'content-length': 'not-a-number' }, + { 'content-encoding': 'gzip' }, + ]) { + const { auth, calls } = fixture(); + await assertSafeFailure(await auth.login(mutation('/api/login', CREDENTIALS, headers)), 400); + assert.equal(calls.length, 0); + } +}); + +test('request bounds apply across chunks rather than trusting Content-Length', async () => { + const encoder = new TextEncoder(); + const { auth, calls } = fixture(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('{"studentNumber":"2023123456","password":"')); + controller.enqueue(encoder.encode('x'.repeat(4096))); + controller.enqueue(encoder.encode('"}')); + controller.close(); + }, + }); + await assertSafeFailure( + await auth.login( + request('/api/login', { + method: 'POST', + duplex: 'half', + body, + headers: { origin: 'https://attendance.example', 'content-type': 'application/json', 'content-length': '1' }, + }), + ), + 400, + ); + assert.equal(calls.length, 0); +}); + +test('malformed, duplicate, oversized and comma-folded app cookies fail closed before upstream', async () => { + const malformed = [ + `${COOKIE}; ${COOKIE}`, + `${INTERNAL_SESSION_COOKIE}=`, + `${INTERNAL_SESSION_COOKIE}=not-a-jwt`, + `${INTERNAL_SESSION_COOKIE}="${TOKEN}"`, + `${INTERNAL_SESSION_COOKIE}=${TOKEN}%3Bother=x`, + `${COOKIE}, other=x`, + `${INTERNAL_SESSION_COOKIE}=${'x'.repeat(3501)}`, + 'other=' + 'x'.repeat(8193), + ]; + for (const cookie of malformed) { + const { auth, calls } = fixture(); + await assertSafeFailure(await auth.me(request('/api/me', { headers: { cookie } })), 401); + await assertSafeFailure(await auth.login(mutation('/api/login', CREDENTIALS, { cookie })), 401); + assert.equal(calls.length, 0); + } +}); + +test('headers, legacy cookies and an absent session never replace the dedicated app cookie', async () => { + const { auth, calls } = fixture(); + for (const cookie of [ + '', + `CF_Authorization=${TOKEN}`, + `attendance_session=${TOKEN}`, + `__Secure-attendance-session=${TOKEN}`, + ]) { + await assertSafeFailure( + await auth.me( + request('/api/me', { + headers: { + cookie, + authorization: `Bearer ${TOKEN}`, + 'cf-access-jwt-assertion': TOKEN, + 'cf-access-authenticated-user-email': 'member@example.test', + }, + }), + ), + 401, + ); + } + assert.equal(calls.length, 0); +}); + +test('syntactically valid forged JWT is checked by Internal, never locally authorized', async () => { + const { auth, calls } = fixture([new Response(null, { status: 401 })]); + const forged = 'eyJhbGciOiJub25lIn0.eyJzdWIiOiI0MiIsImV4cCI6OTk5OTk5OTk5OX0.Zm9yZ2Vk'; + const response = await auth.me(request('/api/me', { headers: { cookie: `${INTERNAL_SESSION_COOKIE}=${forged}` } })); + await assertSafeFailure(response, 401); + assert.equal(calls[0].url, SESSION_URL); + assert.equal(calls[0].headers.get('authorization'), `Bearer ${forged}`); + assert.ok(cookieHeader(response).includes('Max-Age=0')); +}); + +test('every me and private-route verification observes live revocation instead of cached membership', async () => { + const { auth, calls } = fixture([ + Response.json({ ...SESSION }), + new Response(null, { status: 401 }), + new Response(null, { status: 401 }), + ]); + const first = await auth.me(request()); + assert.deepEqual(await first.json(), { authenticated: true, ...SESSION, servedAt: NOW }); + assert.equal(first.headers.get('set-cookie'), null); + await assertSafeFailure(await auth.me(request()), 401); + const guard = await auth.verify(request('/api/automation')); + assert.equal(guard.ok, false); + await assertSafeFailure(guard.response, 401); + assert.equal(calls.length, 3); + assert.ok(calls.every((call) => call.url === SESSION_URL)); +}); + +test('verified-session result is token-free and provides the absolute SSE expiry boundary', async () => { + const { auth } = fixture([Response.json({ ...SESSION, expiresAt: NOW + 12_345 })]); + const result = await auth.verify(request('/api/events')); + assert.deepEqual(result, { ok: true, session: { ...SESSION, expiresAt: NOW + 12_345 } }); + assert.equal(Math.min(NOW + 60_000, result.session.expiresAt), NOW + 12_345); + assert.equal(JSON.stringify(result).includes(TOKEN), false); +}); + +test('invalid login contract cannot mint a session or fall back to another token type', async () => { + for (const change of [ + { accessToken: '' }, + { accessToken: 'opaque' }, + { accessToken: 'a.b.c; injected=1' }, + { accessToken: 'a'.repeat(3501) + '.b.c' }, + { tokenType: 'Basic' }, + { tokenType: 'bearer' }, + { expiresIn: 901 }, + { expiresIn: 0 }, + { expiresIn: '900' }, + { email: 'directory@example.test' }, + ]) { + const { auth, calls } = fixture([loginPayload(change)]); + const response = await auth.login(mutation()); + await assertSafeFailure(response, 503); + assert.equal(calls.length, 1); + assert.equal(response.headers.get('set-cookie'), null); + } +}); + +test('session requires the exact real Internal metadata contract and bounded live expiry', async () => { + const invalid = [ + null, + [], + {}, + { ...SESSION, memberId: 42 }, + { ...SESSION, memberId: '' }, + { ...SESSION, memberId: '0' }, + { ...SESSION, name: '' }, + { ...SESSION, name: ' \n ' }, + { ...SESSION, name: 'name\u0000' }, + { ...SESSION, name: '가'.repeat(201) }, + { ...SESSION, expiresAt: NOW }, + { ...SESSION, expiresAt: NOW - 1 }, + { ...SESSION, expiresAt: NOW + 900_001 }, + { ...SESSION, expiresAt: NOW + 1.5 }, + { ...SESSION, expiresAt: String(SESSION.expiresAt) }, + { ...SESSION, active: true }, + { memberId: '42', name: '홍길동', email: 'member@example.test' }, + ]; + for (const value of invalid) { + const { auth } = fixture([Response.json(value)]); + await assertSafeFailure(await auth.me(request()), 503); + } +}); + +test('redirects, unexpected effective URL and non-JSON upstream responses are never followed or exposed', async () => { + const responses = [ + Response.redirect('https://attacker.example', 302), + new Response('upstream-diagnostic', { headers: { 'content-type': 'text/html' } }), + new Response('{}', { headers: { 'content-type': 'application/json; charset=iso-8859-1' } }), + new Response('{}', { headers: { 'content-type': 'application/json', 'content-encoding': 'gzip' } }), + new Response(new Uint8Array([0xc3, 0x28]), { headers: { 'content-type': 'application/json' } }), + new Response('{', { headers: { 'content-type': 'application/json' } }), + new Response('{}', { headers: { 'content-type': 'application/json', 'content-length': '8193' } }), + new Response('x'.repeat(8193), { headers: { 'content-type': 'application/json', 'content-length': '1' } }), + ]; + const redirected = Response.json(SESSION); + Object.defineProperty(redirected, 'redirected', { value: true }); + responses.push(redirected); + const wrongUrl = Response.json(SESSION); + Object.defineProperty(wrongUrl, 'url', { value: 'https://attacker.example/session' }); + responses.push(wrongUrl); + for (const response of responses) { + const { auth, calls } = fixture([response]); + await assertSafeFailure(await auth.me(request()), 503); + assert.equal(calls.length, 1); + } +}); + +test('strict UTF-8 decoder supports multi-byte names split across upstream chunks', async () => { + const bytes = new TextEncoder().encode(JSON.stringify(SESSION)); + const body = new ReadableStream({ + start(controller) { + for (let offset = 0; offset < bytes.length; offset++) controller.enqueue(bytes.slice(offset, offset + 1)); + controller.close(); + }, + }); + const { auth } = fixture([new Response(body, { headers: { 'content-type': 'application/json; charset=UTF-8' } })]); + assert.deepEqual(await auth.verify(request()), { ok: true, session: SESSION }); +}); + +test('deadline includes upstream headers and aborts a transport ignoring cancellation', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let signal; + const { auth } = fixture([ + (_url, init) => { + signal = init.signal; + return new Promise(() => {}); + }, + ]); + const pending = auth.me(request()); + t.mock.timers.tick(5_000); + await assertSafeFailure(await pending, 503); + assert.equal(signal.aborted, true); +}); + +test('deadline covers complete upstream body and cancels a stalled reader', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const entered = Promise.withResolvers(); + let canceled = false; + const body = new ReadableStream({ + pull() { + entered.resolve(); + return new Promise(() => {}); + }, + cancel() { + canceled = true; + }, + }); + const { auth } = fixture([new Response(body, { headers: { 'content-type': 'application/json' } })]); + const pending = auth.me(request()); + await entered.promise; + t.mock.timers.tick(5_000); + await assertSafeFailure(await pending, 503); + assert.equal(canceled, true); +}); + +test('late transport success after timeout cannot issue a session cookie or start another request', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const arrived = Promise.withResolvers(); + const entered = Promise.withResolvers(); + let canceled = false; + const response = new Response( + new ReadableStream({ + cancel() { + canceled = true; + }, + }), + { + headers: { 'content-type': 'application/json' }, + }, + ); + const { auth, calls } = fixture([ + () => { + entered.resolve(); + return arrived.promise; + }, + ]); + const pending = auth.login(mutation()); + await entered.promise; + t.mock.timers.tick(5_000); + const result = await pending; + await assertSafeFailure(result, 503); + assert.equal(result.headers.get('set-cookie'), null); + arrived.resolve(response); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(calls.length, 1); + assert.equal(canceled, true); +}); + +test('bounded login request body times out without calling Internal', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let canceled = false; + const { auth, calls } = fixture(); + const pending = auth.login( + request('/api/login', { + method: 'POST', + duplex: 'half', + headers: { origin: 'https://attendance.example', 'content-type': 'application/json' }, + body: new ReadableStream({ + cancel() { + canceled = true; + }, + }), + }), + ); + t.mock.timers.tick(5_000); + await assertSafeFailure(await pending, 400); + assert.equal(calls.length, 0); + assert.equal(canceled, true); +}); + +test('client cancellation aborts session validation and fails closed', async () => { + const controller = new AbortController(); + const entered = Promise.withResolvers(); + let signal; + const { auth } = fixture([ + (_url, init) => { + signal = init.signal; + entered.resolve(); + return new Promise(() => {}); + }, + ]); + const pending = auth.me(request('/api/me', { signal: controller.signal })); + await entered.promise; + controller.abort(); + await assertSafeFailure(await pending, 503); + assert.equal(signal.aborted, true); +}); + +test('cookie age never exceeds 900 seconds and includes elapsed upstream time', async () => { + let clock = NOW; + const auth = createInternalAuth(ENV, { + now: () => clock, + fetch: async (url) => { + if (url === LOGIN_URL) { + clock += 1_200; + return loginPayload(); + } + assert.equal(url, SESSION_URL); + clock += 2_400; + return Response.json({ ...SESSION, expiresAt: NOW + 900_000 }); + }, + }); + const response = await auth.login(mutation()); + assert.equal(response.status, 200); + assert.match(cookieHeader(response), /Max-Age=896$/); + assert.deepEqual(await response.json(), { + authenticated: true, + ...SESSION, + expiresAt: NOW + 900_000, + servedAt: NOW + 3_600, + }); +}); + +test('logout clears only this app cookie, never calls Internal or writes attendance', async () => { + const { auth, calls } = fixture(); + const response = await auth.logout( + mutation('/api/logout', {}, { cookie: `${COOKIE}; CF_Authorization=outer-session` }), + ); + assert.equal(response.status, 200); + assert.equal( + cookieHeader(response), + `${INTERNAL_SESSION_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=0`, + ); + assert.deepEqual(await response.json(), { authenticated: false, servedAt: NOW }); + assert.equal(calls.length, 0); + assert.equal(response.headers.get('set-cookie').includes('CF_Authorization'), false); + const unconfigured = fixture([], { INTERNAL_AUTH_CLIENT_SECRET: undefined }); + assert.equal((await unconfigured.auth.logout(mutation('/api/logout', {}))).status, 200); +}); + +test('logout requires an empty bounded JSON object, including when app cookie is invalid', async () => { + for (const body of [null, [], { memberId: '42' }, { password: CREDENTIALS.password }, 'x'.repeat(4097)]) { + const { auth, calls } = fixture(); + const response = await auth.logout(mutation('/api/logout', body)); + await assertSafeFailure(response, 400); + assert.equal(response.headers.get('set-cookie'), null); + assert.equal(calls.length, 0); + } + const { auth } = fixture(); + const response = await auth.logout(mutation('/api/logout', {}, { cookie: `${COOKIE}; ${COOKIE}` })); + await assertSafeFailure(response, 401); + assert.ok(cookieHeader(response).includes('Max-Age=0')); +}); diff --git a/apps/attendance/test/viewer.test.mjs b/apps/attendance/test/viewer.test.mjs index d692379..2e0d8d2 100644 --- a/apps/attendance/test/viewer.test.mjs +++ b/apps/attendance/test/viewer.test.mjs @@ -2,7 +2,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createViewer } from '../src/viewer.ts'; -const worker = createViewer('
    readonly snapshot viewer
    '); +const worker = createViewer('
    readonly snapshot viewer
    ', { + fetch: async (url, init) => { + assert.equal(url, 'https://bcsd-attendance-auth.koreatech.in/v1/members/attendance-session'); + assert.equal(new Headers(init.headers).get('authorization'), 'Bearer a.b.c'); + return Response.json({ memberId: '42', name: '홍길동', expiresAt: Date.now() + 899_000 }); + }, +}); const NOW = Date.UTC(2026, 10, 14, 1); const context = { access: { aud: 'private-app' } }; const paths = ['/api/state', '/api/monitor', '/api/koin-health', '/api/worker-health', '/api/spotify', '/api/jam']; @@ -20,6 +26,8 @@ function harness(value) { let reads = 0; const env = { CF_ACCESS_AUD: 'private-app', + INTERNAL_AUTH_CLIENT_ID: 'isolated-viewer-client', + INTERNAL_AUTH_CLIENT_SECRET: 'isolated-viewer-secret', VIEW_STATE: { async get(key, format) { reads++; @@ -30,7 +38,14 @@ function harness(value) { }, }; const request = (path, options = {}, ctx = context) => - worker.fetch(new Request('https://viewer.example' + path, options), env, ctx); + worker.fetch( + new Request('https://viewer.example' + path, { + ...options, + headers: { cookie: '__Host-attendance-session=a.b.c', ...options.headers }, + }), + env, + ctx, + ); return { env, request, reads: () => reads }; } @@ -47,7 +62,7 @@ test('the viewer rejects client-asserted authentication and mutations without re 403, ); assert.equal((await h.request('/api/state', {}, { access: { aud: 'other-app' } })).status, 403); - for (const path of ['/api/taps', '/api/members', '/api/login', '/api/logout', '/api/camera']) + for (const path of ['/api/taps', '/api/members', '/api/camera']) assert.equal((await h.request(path, { method: 'POST' })).status, 404); assert.equal((await h.request('/api/state', { method: 'POST' })).status, 405); assert.equal((await h.request('/api/state?url=https://attacker.example')).status, 400); @@ -81,6 +96,7 @@ test('missing, malformed and stale Pi snapshots fail closed while independent au const result = await monitor.json(); assert.equal(result.state, 'unknown'); assert.equal(result.data, null); + assert.equal(result.servedAt, NOW); assert.equal(result.rocky.state, 'unknown'); assert.equal(h.reads(), before); } diff --git a/apps/attendance/test/worker.test.mjs b/apps/attendance/test/worker.test.mjs new file mode 100644 index 0000000..f09b84e --- /dev/null +++ b/apps/attendance/test/worker.test.mjs @@ -0,0 +1,739 @@ +import assert from 'node:assert/strict'; +import test, { before, after, mock } from 'node:test'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createViewer } from '../src/viewer.ts'; +import { buildViewer } from '../scripts/build-viewer.mjs'; + +const AUD = 'isolated-viewer-test-audience'; +const ctx = { access: { aud: AUD } }; +const SESSION_TOKEN = 'a.b.c'; +const SESSION_COOKIE = `__Host-attendance-session=${SESSION_TOKEN}`; +const AUTH_ENV = { + INTERNAL_AUTH_CLIENT_ID: 'isolated-viewer-client', + INTERNAL_AUTH_CLIENT_SECRET: 'isolated-viewer-secret', +}; +const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aTyoAAAAASUVORK5CYII='; +const jsonPaths = ['/api/state', '/api/monitor', '/api/koin-health', '/api/worker-health', '/api/spotify', '/api/jam']; +const paths = [ + '/', + '/health', + '/api/events', + '/api/view-status', + ...jsonPaths, + '/api/automation', + '/api/wifi-qr/2.4', + '/api/wifi-qr/5', + '/api/jam-qr', +]; +let worker, directory, html; +before(async () => { + mock.method(globalThis, 'fetch', () => { + assert.fail('Unexpected real network fetch'); + }); + directory = await mkdtemp(join(tmpdir(), 'attendance-viewer-')); + html = await readFile(await buildViewer(join(directory, 'dashboard.html')), 'utf8'); + worker = createViewer(html, { + fetch: async (url, init) => { + assert.equal(url, 'https://bcsd-attendance-auth.koreatech.in/v1/members/attendance-session'); + assert.equal(new Headers(init.headers).get('authorization'), `Bearer ${SESSION_TOKEN}`); + return Response.json({ memberId: '42', name: '홍길동', expiresAt: Date.now() + 899_000 }); + }, + }); +}); +after(async () => { + mock.restoreAll(); + if (directory) await rm(directory, { recursive: true, force: true }); +}); + +function snapshot(now = Date.now()) { + return { + version: 1, + observedAt: now - 1000, + publishedAt: now - 500, + payloads: Object.fromEntries( + jsonPaths.map((path) => [ + path, + { + status: 200, + body: { path, now: now - 2000, collectedAt: now - 3000, actual: true }, + }, + ]), + ), + assets: { '/api/wifi-qr/2.4': PNG, '/api/wifi-qr/5': PNG, '/api/jam-qr': PNG }, + }; +} +function environment(value = snapshot()) { + const store = { + raw: value === null ? null : JSON.stringify(value), + reads: 0, + async get(key, type) { + assert.equal(key, 'clubroom:view:v1'); + assert.equal(type, 'arrayBuffer'); + this.reads++; + return this.raw === null || this.raw instanceof ArrayBuffer + ? this.raw + : new TextEncoder().encode(this.raw).buffer; + }, + }; + return { store, CF_ACCESS_AUD: AUD, VIEW_STATE: store, ...AUTH_ENV }; +} +function get(env, path = '/api/state', context = ctx, options = {}) { + return worker.fetch( + new Request(`https://attendance.example${path}`, { + ...options, + headers: { cookie: SESSION_COOKIE, ...options.headers }, + }), + env, + context, + ); +} +function assertPrivate(response) { + assert.equal(response.headers.get('cache-control'), 'private, no-store'); + assert.equal(response.headers.get('x-content-type-options'), 'nosniff'); + assert.equal(response.headers.get('x-frame-options'), 'DENY'); + assert.equal(response.headers.get('x-bcsd-view-mode'), 'eventually-consistent-snapshot'); + assert.ok(response.headers.get('content-security-policy').includes("frame-ancestors 'none'")); + assert.match(response.headers.get('content-security-policy'), /img-src [^;]*\bblob:/); + assert.match(response.headers.get('content-security-policy'), /connect-src 'self';/); + for (const name of ['set-cookie', 'location', 'access-control-allow-origin', 'www-authenticate']) + assert.equal(response.headers.get(name), null); +} + +test('missing/wrong platform audience and spoofed headers never cause any KV access', async () => { + const env = environment(); + for (const context of [ + undefined, + {}, + { access: {} }, + { access: { aud: 'other-worker' } }, + { access: { aud: [AUD] } }, + ]) { + for (const path of paths) { + const response = await worker.fetch( + new Request(`https://attendance.example${path}`, { + headers: { + 'cf-access-jwt-assertion': 'unverified.jwt.assertion', + 'cf-access-authenticated-user-email': 'admin@example.test', + 'cf-warp-tag-id': AUD, + authorization: 'Bearer client-token', + cookie: 'CF_Authorization=client-token', + }, + }), + env, + context, + ); + assert.equal(response.status, 403); + assertPrivate(response); + } + } + assert.equal(env.store.reads, 0); + assert.equal((await get(env)).status, 200); + assert.equal(env.store.reads, 1); +}); + +test('missing dedicated namespace/audience fails closed and legacy attendance KV is never used', async () => { + for (const change of [ + { VIEW_STATE: undefined }, + { CF_ACCESS_AUD: undefined }, + { CF_ACCESS_AUD: '' }, + { CF_ACCESS_AUD: ` ${AUD}` }, + { CF_ACCESS_AUD: 'x'.repeat(257) }, + ]) { + const env = Object.assign(environment(), change); + assert.equal((await get(env)).status, 503); + assert.equal(env.store.reads, 0); + } + const legacy = { ATTENDANCE: environment().store, CF_ACCESS_AUD: AUD, ...AUTH_ENV }; + assert.equal((await get(legacy)).status, 503); + assert.equal(legacy.ATTENDANCE.reads, 0); +}); + +test('build serves the current complete LAN dashboard with truthful hosted notice and no KV HTML', async () => { + const env = environment(null); + const response = await get(env, '/'); + assert.equal(response.status, 200); + assertPrivate(response); + assert.equal(response.headers.get('content-type'), 'text/html; charset=utf-8'); + assert.equal(await response.text(), html); + for (const marker of [ + '', + 'monitor-view', + 'spotify-now', + 'cloud-snapshot', + '원격 스냅샷', + '/api/view-status', + ]) { + assert.ok(html.includes(marker), marker); + } + assert.ok(!html.includes('변경 즉시 갱신')); + assert.ok(!html.includes('임시 LAN 운영 · 로그인 없음')); + assert.ok(!html.includes('new EventSource(')); + const head = await get(env, '/', ctx, { method: 'HEAD' }); + assert.equal(head.status, 200); + assert.equal(await head.text(), ''); + assert.equal(env.store.reads, 0); +}); + +test('every JSON route preserves actual data, original clocks and error statuses', async () => { + const value = snapshot(); + value.payloads['/api/koin-health'].status = 503; + value.payloads['/api/spotify'].status = 401; + const env = environment(value); + for (const path of jsonPaths) { + const response = await get(env, path); + assert.equal(response.status, value.payloads[path].status); + assert.deepEqual(await response.json(), value.payloads[path].body); + assertPrivate(response); + assert.equal(response.headers.get('x-bcsd-snapshot-observed-at'), String(value.observedAt)); + assert.equal(response.headers.get('x-bcsd-snapshot-published-at'), String(value.publishedAt)); + } + const health = await (await get(env, '/health')).json(); + assert.equal(health.mode, 'hosted_snapshot'); + assert.equal(health.consistency, 'eventual'); + assert.equal(health.observedAt, value.observedAt); +}); + +test('view status exposes only actual snapshot availability/timestamps and fails closed on stale/missing data', async (t) => { + const now = 1_800_000_000_000; + t.mock.timers.enable({ apis: ['Date'], now }); + const value = snapshot(now); + const env = environment(value); + const fresh = await get(env, '/api/view-status'); + assert.equal(fresh.status, 200); + assertPrivate(fresh); + assert.deepEqual(await fresh.json(), { + available: true, + observedAt: value.observedAt, + publishedAt: value.publishedAt, + ageMs: 1000, + servedAt: now, + }); + t.mock.timers.tick(300_001); + const stale = await get(env, '/api/view-status'); + assert.equal(stale.status, 503); + assert.deepEqual(await stale.json(), { + available: false, + observedAt: value.observedAt, + publishedAt: value.publishedAt, + ageMs: 301001, + servedAt: now + 300_001, + }); + assert.equal(stale.headers.get('x-bcsd-snapshot-observed-at'), String(value.observedAt)); + env.store.raw = null; + const missing = await get(env, '/api/view-status'); + assert.equal(missing.status, 503); + assert.deepEqual(await missing.json(), { + available: false, + observedAt: null, + publishedAt: null, + ageMs: null, + servedAt: now + 300_001, + }); + assert.equal(missing.headers.get('x-bcsd-snapshot-observed-at'), null); + env.VIEW_STATE = undefined; + assert.equal((await get(env, '/')).status, 200); + assert.equal((await get(env, '/api/view-status')).status, 503); +}); + +test('camera/auth/admin/writes/arbitrary paths/queries and non-root HEAD never read KV', async () => { + const env = environment(); + for (const path of [ + '/api/taps', + '/api/members', + '/api/admin', + '/api/camera', + '/api/camera-stream', + '/api/camera/snapshot', + '/api/camera.jpg', + '/api/wifi-qr/config', + '/api/state/', + '/api/%73tate', + '//attacker.example/api/state', + '/http://attacker.example', + '/constructor', + '/__proto__', + ]) { + assert.equal((await get(env, path)).status, 404, path); + } + for (const path of paths) { + for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) + assert.equal((await get(env, path, ctx, { method })).status, 405); + if (path !== '/') assert.equal((await get(env, path, ctx, { method: 'HEAD' })).status, 405); + assert.equal((await get(env, `${path}?url=http://attacker.example`)).status, 400); + assert.equal((await get(env, `${path}?`)).status, 400); + } + assert.equal(env.store.reads, 0); +}); + +test('client auth/cookies/forwarding/conditional headers cannot select data or become response headers', async () => { + const value = snapshot(); + const env = environment(value); + const response = await get(env, '/api/state', ctx, { + headers: { + authorization: 'Bearer private', + cookie: `${SESSION_COOKIE}; private-cookie=value`, + 'cf-access-jwt-assertion': 'private-assertion', + origin: 'https://attacker.example', + host: 'attacker.example', + forwarded: 'host=attacker.example', + 'x-forwarded-for': '1.2.3.4', + range: 'bytes=0-5', + 'if-none-match': 'private-etag', + }, + }); + assert.deepEqual(await response.json(), value.payloads['/api/state'].body); + assertPrivate(response); + assert.ok(!JSON.stringify([...response.headers]).includes('private-cookie')); + assert.equal(env.store.reads, 1); +}); + +test('missing, invalid UTF-8, malformed, null, oversized and unavailable snapshots fail closed', async () => { + for (const raw of [null, '', '{', 'null', '[]', '{}', new Uint8Array([0xff]).buffer, new ArrayBuffer(2_097_153)]) { + const env = environment(); + env.store.raw = raw; + const response = await get(env); + assert.equal(response.status, 503); + assertPrivate(response); + } + const env = environment(); + env.store.get = async () => { + throw new Error('private namespace detail'); + }; + const response = await get(env); + assert.equal(response.status, 503); + assert.ok(!(await response.text()).includes('private namespace')); +}); + +test('snapshot schema, timestamps and JSON status validation reject partial or unexpected data', async () => { + const changes = [ + (s) => { + s.version = 2; + }, + (s) => { + delete s.payloads['/api/monitor']; + }, + (s) => { + s.payloads['/api/camera'] = { status: 200, body: {} }; + }, + (s) => { + s.observedAt = '1'; + }, + (s) => { + s.publishedAt = s.observedAt - 1; + }, + (s) => { + s.publishedAt = Date.now() + 60_000; + }, + (s) => { + s.observedAt = 0; + }, + (s) => { + s.admin = { secret: 'private' }; + }, + (s) => { + s.payloads['/api/state'].headers = { 'set-cookie': 'secret' }; + }, + (s) => { + s.payloads['/api/state'].body = null; + }, + (s) => { + delete s.assets['/api/wifi-qr/5']; + }, + ]; + for (const status of [101, 199, 204, 205, 301, 302, 304, 307, 600, 200.5, '200']) { + changes.push((s) => { + s.payloads['/api/state'].status = status; + }); + } + for (const change of changes) { + const value = snapshot(); + change(value); + const response = await get(environment(value)); + assert.equal(response.status, 503); + assert.equal((await response.json()).error, 'snapshot_malformed'); + } +}); + +test('observation age alone enforces cutoff: exactly five minutes accepted, older data rejected', async (t) => { + const now = 1_800_000_000_000; + t.mock.timers.enable({ apis: ['Date'], now }); + const value = snapshot(now); + value.observedAt = now - 300_000; + value.publishedAt = now; + const env = environment(value); + assert.equal((await get(env)).status, 200); + t.mock.timers.tick(1); + for (const path of [...jsonPaths, '/health', '/api/events', '/api/wifi-qr/5']) { + const response = await get(env, path); + assert.equal(response.status, 503, path); + assert.equal((await response.json()).error, 'snapshot_stale'); + assert.equal(response.headers.get('x-bcsd-snapshot-observed-at'), String(value.observedAt)); + assert.equal(response.headers.get('x-bcsd-snapshot-age-ms'), '300001'); + } +}); + +test('PNG assets pass exact bytes with bounds and signature checks; optional Jam absence is not fabricated', async () => { + const value = snapshot(); + for (const path of ['/api/wifi-qr/2.4', '/api/wifi-qr/5', '/api/jam-qr']) { + const response = await get(environment(value), path); + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type'), 'image/png'); + assertPrivate(response); + assert.deepEqual(Buffer.from(await response.arrayBuffer()), Buffer.from(PNG, 'base64')); + } + delete value.assets['/api/jam-qr']; + assert.equal((await get(environment(value), '/api/jam-qr')).status, 404); + for (const png of [ + '', + 'not-png', + PNG + '\n', + PNG.replace('iVBOR', 'AAAAA'), + PNG.slice(0, -16) + 'AAAAAAAAAAAAAAAA', + 'iVBORw0KGgoA' + 'AAAA'.repeat(90_000), + ]) { + const invalid = snapshot(); + invalid.assets['/api/wifi-qr/5'] = png; + assert.equal((await get(environment(invalid), '/api/wifi-qr/5')).status, 503); + } + const invalid = snapshot(); + invalid.assets['/api/camera'] = PNG; + assert.equal((await get(environment(invalid))).status, 503); +}); + +test('SSE emits initial/version-change state and pings from atomic eventually-consistent KV reads', async (t) => { + const now = 1_800_000_000_000; + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now }); + const env = environment(snapshot(now)); + const response = await get(env, '/api/events'); + assert.equal(response.headers.get('content-type'), 'text/event-stream; charset=utf-8'); + const reader = response.body.getReader(); + const decode = (chunk) => new TextDecoder().decode(chunk.value); + assert.match(decode(await reader.read()), /event: state\ndata: \{"version":/); + let pending = reader.read(); + t.mock.timers.tick(10_000); + assert.equal(decode(await pending), 'event: ping\ndata: {}\n\n'); + const next = snapshot(Date.now()); + env.store.raw = JSON.stringify(next); + pending = reader.read(); + t.mock.timers.tick(10_000); + assert.equal(decode(await pending), `event: state\ndata: {"version":"${next.observedAt}:${next.publishedAt}"}\n\n`); + assert.equal(env.store.reads, 3); + await reader.cancel(); + t.mock.timers.tick(120_000); + assert.equal(env.store.reads, 3); +}); + +test('SSE lifetime is at most sixty seconds even when unread', async (t) => { + const now = 1_800_000_000_000; + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now }); + const env = environment(snapshot(now)); + const response = await get(env, '/api/events'); + t.mock.timers.tick(60_000); + assert.ok((await response.text()).startsWith('event: state\n')); + const reads = env.store.reads; + t.mock.timers.tick(120_000); + assert.equal(env.store.reads, reads); +}); + +test('SSE closes when the atomic snapshot disappears instead of streaming stale/private payloads', async (t) => { + const now = 1_800_000_000_000; + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now }); + const env = environment(snapshot(now)); + const reader = (await get(env, '/api/events')).body.getReader(); + await reader.read(); + env.store.raw = null; + const pending = reader.read(); + t.mock.timers.tick(10_000); + assert.equal( + new TextDecoder().decode((await pending).value), + 'event: unavailable\ndata: {"error":"snapshot_missing"}\n\n', + ); + assert.equal((await reader.read()).done, true); +}); + +test('client disconnect cancels SSE timers and prevents any subsequent KV read', async (t) => { + const now = 1_800_000_000_000; + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now }); + const env = environment(snapshot(now)); + const client = new AbortController(); + const reader = (await get(env, '/api/events', ctx, { signal: client.signal })).body.getReader(); + await reader.read(); + const pending = reader.read(); + client.abort(); + assert.equal((await pending).done, true); + t.mock.timers.tick(120_000); + assert.equal(env.store.reads, 1); + assert.equal((await get(env, '/api/events', ctx, { signal: client.signal })).status, 499); + assert.equal(env.store.reads, 1); +}); + +test('native platform context gates all auth endpoints before any Internal or data calls', async () => { + let upstream = 0; + const guarded = createViewer(html, { + fetch: async () => { + upstream++; + throw new Error('unexpected transport'); + }, + }); + const env = environment(); + for (const path of ['/api/login', '/api/me', '/api/logout']) { + const mutation = path !== '/api/me'; + const response = await guarded.fetch( + new Request(`https://attendance.example${path}`, { + method: mutation ? 'POST' : 'GET', + headers: { + cookie: SESSION_COOKIE, + authorization: 'Basic attacker', + origin: 'https://attendance.example', + 'content-type': 'application/json', + }, + body: mutation ? '{}' : undefined, + }), + env, + {}, + ); + assert.equal(response.status, 403); + assertPrivate(response); + } + assert.equal(upstream, 0); + assert.equal(env.store.reads, 0); +}); + +test('login, live me and local logout preserve private response headers without exposing JWTs or reading KV', async () => { + const seen = []; + const expiresAt = Date.now() + 800_000; + const guarded = createViewer(html, { + fetch: async (url, init) => { + seen.push(url); + if (url.endsWith('/attendance-login')) { + assert.deepEqual(JSON.parse(init.body), { studentNumber: '20231234', password: 'isolated password' }); + return Response.json({ accessToken: SESSION_TOKEN, tokenType: 'Bearer', expiresIn: 900 }); + } + assert.equal(new Headers(init.headers).get('authorization'), `Bearer ${SESSION_TOKEN}`); + return Response.json({ memberId: '42', name: '홍길동', expiresAt }); + }, + }); + const env = environment(); + const send = (path, body) => + guarded.fetch( + new Request(`https://attendance.example${path}`, { + method: body ? 'POST' : 'GET', + headers: { cookie: SESSION_COOKIE, origin: 'https://attendance.example', 'content-type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + }), + env, + ctx, + ); + for (const response of [ + await send('/api/login', { studentNumber: '20231234', password: 'isolated password' }), + await send('/api/me'), + ]) { + assert.equal(response.status, 200); + const body = await response.json(); + assert.deepEqual(Object.keys(body).sort(), ['authenticated', 'expiresAt', 'memberId', 'name', 'servedAt']); + assert.equal(body.authenticated, true); + assert.equal(body.expiresAt, expiresAt); + assert.equal(body.memberId, '42'); + assert.equal(body.name, '홍길동'); + assert.equal(typeof body.servedAt, 'number'); + assert.equal(response.headers.get('www-authenticate'), null); + assert.equal(response.headers.get('cache-control'), 'private, no-store'); + assert.ok(response.headers.get('content-security-policy')); + } + const logout = await send('/api/logout', {}); + assert.equal(logout.status, 200); + assert.match(logout.headers.get('set-cookie'), /Max-Age=0/); + assert.equal(seen.length, 3); + assert.ok(seen.every((url) => url.startsWith('https://bcsd-attendance-auth.koreatech.in/v1/'))); + assert.equal(env.store.reads, 0); +}); + +test('all private Pi and automation routes require live Internal membership before KV or service reads', async () => { + for (const status of [401, 503]) { + let authReads = 0; + let serviceReads = 0; + const guarded = createViewer(html, { + fetch: async (url) => { + assert.equal(url, 'https://bcsd-attendance-auth.koreatech.in/v1/members/attendance-session'); + authReads++; + return new Response(null, { status }); + }, + }); + const env = environment(); + env.ERROR_AUTOMATION = { + fetch: async () => { + serviceReads++; + throw new Error('must not read'); + }, + }; + env.ERROR_MONITOR_TOKEN = 'm'.repeat(32); + for (const path of paths.filter((path) => path !== '/')) { + const response = await guarded.fetch( + new Request(`https://attendance.example${path}`, { + headers: { cookie: SESSION_COOKIE }, + }), + env, + ctx, + ); + assert.equal(response.status, status, path); + assert.equal(response.headers.get('www-authenticate'), null); + } + assert.equal(authReads, paths.length - 1); + assert.equal(env.store.reads, 0); + assert.equal(serviceReads, 0); + } + const env = environment(); + for (const path of paths.filter((path) => path !== '/')) { + const response = await worker.fetch( + new Request(`https://attendance.example${path}`, { + headers: { authorization: 'Basic attacker', cookie: 'CF_Authorization=forged' }, + }), + env, + ctx, + ); + assert.equal(response.status, 401, path); + } + assert.equal(env.store.reads, 0); +}); + +test('static root and HEAD never validate membership or read private data, even without machine secrets', async () => { + const guarded = createViewer('
    login shell
    ', { + fetch: async () => assert.fail('root must not call Internal'), + }); + const env = environment(); + delete env.INTERNAL_AUTH_CLIENT_ID; + delete env.INTERNAL_AUTH_CLIENT_SECRET; + for (const method of ['GET', 'HEAD']) { + const response = await guarded.fetch(new Request('https://attendance.example/', { method }), env, ctx); + assert.equal(response.status, 200); + assert.equal(await response.text(), method === 'GET' ? '
    login shell
    ' : ''); + } + assert.equal(env.store.reads, 0); +}); + +test('pending membership validation cannot race ahead into snapshot reads', async () => { + const entered = Promise.withResolvers(); + const validation = Promise.withResolvers(); + const guarded = createViewer(html, { + fetch: async () => { + entered.resolve(); + return validation.promise; + }, + }); + const env = environment(); + const pending = guarded.fetch( + new Request('https://attendance.example/api/state', { headers: { cookie: SESSION_COOKIE } }), + env, + ctx, + ); + await entered.promise; + assert.equal(env.store.reads, 0); + validation.resolve(Response.json({ memberId: '42', name: '홍길동', expiresAt: Date.now() + 899_000 })); + assert.equal((await pending).status, 200); + assert.equal(env.store.reads, 1); +}); + +test('SSE revalidates current membership before each poll and stops revoked sessions before KV reads', async (t) => { + const now = 1_800_000_000_000; + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now }); + let authReads = 0; + const guarded = createViewer(html, { + fetch: async () => { + authReads++; + return authReads === 1 + ? Response.json({ memberId: '42', name: '홍길동', expiresAt: now + 899_000 }) + : new Response(null, { status: 401 }); + }, + }); + const env = environment(snapshot(now)); + const response = await guarded.fetch( + new Request('https://attendance.example/api/events', { headers: { cookie: SESSION_COOKIE } }), + env, + ctx, + ); + const reader = response.body.getReader(); + await reader.read(); + const closed = reader.read(); + t.mock.timers.tick(10_000); + assert.equal((await closed).done, true); + assert.equal(authReads, 2); + assert.equal(env.store.reads, 1); + t.mock.timers.tick(120_000); + assert.equal(authReads, 2); + assert.equal(env.store.reads, 1); +}); + +test('SSE closes at Internal expiry even when unread and without another private read', async (t) => { + const now = 1_800_000_000_000; + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now }); + let authReads = 0; + const guarded = createViewer(html, { + fetch: async () => { + authReads++; + return Response.json({ memberId: '42', name: '홍길동', expiresAt: now + 5_000 }); + }, + }); + const env = environment(snapshot(now)); + const response = await guarded.fetch( + new Request('https://attendance.example/api/events', { headers: { cookie: SESSION_COOKIE } }), + env, + ctx, + ); + t.mock.timers.tick(5_000); + assert.match(await response.text(), /^event: state/); + t.mock.timers.tick(120_000); + assert.equal(authReads, 1); + assert.equal(env.store.reads, 1); +}); + +test('a snapshot finishing after session expiry never returns its private payload', async (t) => { + const now = 1_800_000_000_000; + t.mock.timers.enable({ apis: ['Date'], now }); + const guarded = createViewer(html, { + fetch: async () => Response.json({ memberId: '42', name: '홍길동', expiresAt: now + 1_000 }), + }); + const env = environment(snapshot(now)); + env.store.get = async () => { + t.mock.timers.tick(1_000); + return new TextEncoder().encode(JSON.stringify(snapshot(now))).buffer; + }; + const response = await guarded.fetch( + new Request('https://attendance.example/api/state', { headers: { cookie: SESSION_COOKIE } }), + env, + ctx, + ); + assert.equal(response.status, 401); + assert.equal((await response.json()).error, 'authentication_required'); +}); + +test('SSE revalidation can shorten but never extend the original expiry before the next KV read', async (t) => { + const now = 1_800_000_000_000; + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now }); + let authReads = 0; + const guarded = createViewer(html, { + fetch: async () => { + authReads++; + return Response.json({ memberId: '42', name: '홍길동', expiresAt: now + (authReads === 1 ? 50_000 : 12_000) }); + }, + }); + const env = environment(snapshot(now)); + const response = await guarded.fetch( + new Request('https://attendance.example/api/events', { headers: { cookie: SESSION_COOKIE } }), + env, + ctx, + ); + const reader = response.body.getReader(); + await reader.read(); + const ping = reader.read(); + t.mock.timers.tick(10_000); + assert.equal(new TextDecoder().decode((await ping).value), 'event: ping\ndata: {}\n\n'); + assert.equal(env.store.reads, 2); + const closed = reader.read(); + t.mock.timers.tick(2_000); + assert.equal((await closed).done, true); + t.mock.timers.tick(120_000); + assert.equal(authReads, 2); + assert.equal(env.store.reads, 2); +}); diff --git a/apps/attendance/wrangler.jsonc b/apps/attendance/wrangler.jsonc index f28b6c9..f93d628 100644 --- a/apps/attendance/wrangler.jsonc +++ b/apps/attendance/wrangler.jsonc @@ -6,10 +6,11 @@ "workers_dev": true, "preview_urls": false, "observability": { "enabled": true }, - // 운영 플랫폼 Access audience 등 외부 관리 vars를 보존합니다. + "build": { "command": "node scripts/build-viewer.mjs" }, + // 플랫폼 Access audience 등 외부 관리 vars를 보존합니다. "keep_vars": true, - "rules": [{ "type": "Text", "globs": ["**/*.html"], "fallthrough": true }], - // 원본 출입 원장 KV는 삭제·치환하지 않으며 뷰어는 별도 스냅샷만 읽습니다. + "rules": [{ "type": "Text", "globs": ["**/*.html"], "fallthrough": false }], + // 출입 원장 KV는 변경·삭제하지 않으며 별도 스냅샷만 읽습니다. "kv_namespaces": [{ "binding": "VIEW_STATE", "id": "b519a98db46d4b088baee97a256bf0f0" }], "services": [{ "binding": "ERROR_AUTOMATION", "service": "bcsd-error-automation" }], }