v1.10.88 품질·보안 개선과 미디어 카탈로그 database 전환을 반영한다.

세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-22 11:05:40 +09:00
parent 9bc0f0a90e
commit de767095ba
116 changed files with 8128 additions and 2933 deletions

View File

@@ -1,3 +1,49 @@
## 2026-07-22 v1.10.88 — 개발 미디어 카탈로그를 database로 고정하고 임시 전환 UI를 제거한다
개발에서 reconcile·shadow·database 비교가 끝난 뒤에는 런타임 버튼으로 모드를 바꾸지 않는다. 임시 전환 패널과 `catalog-mode`·`catalog-status` API, 프로세스 메모리 덮어쓰기를 제거하고 `MEDIA_CATALOG_MODE` 환경 변수만 사용한다. 개발 env는 `database`로 고정한다. 운영도 같은 계약으로 가되, 배포 시 마이그레이션과 안전 reconcile을 먼저 끝내고 나서 `database`로 고정한다. DB 조회가 준비되지 않으면 파일시스템 목록으로 대체하는 fallback은 유지한다.
## 2026-07-21 v1.10.88 — 검증·브라우저 정책·미디어 인덱스는 운영 데이터와 단계적으로 분리한다
API와 브라우저 회귀 테스트가 개발 DB와 실제 업로드 디렉터리를 공유하면 테스트 게시물·파일이 남거나 운영에 가까운 데이터를 훼손할 수 있다. Playwright는 실행마다 이름이 `_test`로 끝나는 별도 PostgreSQL 컨테이너와 임시 업로드 루트만 사용하고 종료 시 볼륨과 파일을 제거한다. 테스트 결과를 비결정적으로 만드는 Export worker와 추천 사이트 파비콘 cron은 테스트 환경에서 끄고, DB 상태를 순서대로 검증하는 현재 시나리오는 worker 1개로 직렬 실행한다.
CSP는 안전한 지시어를 즉시 강제하되 기존 사용자 사이트 코드, 광고, 외부 미디어를 한 번에 차단하지 않는다. `base-uri`, `object-src`, `frame-ancestors`만 최소 강제 정책으로 분리하고 실행·스타일·이미지·프레임 출처는 Report-Only에서 관찰한다. 완전한 HTML 문서를 의도적으로 제공하는 `/pages/**`는 기존 콘텐츠의 `<base>`와 독립 자산 구성을 보존하기 위해 강제 정책에서 제외하고 보고만 수집한다. 보고 API는 인증 없는 브라우저 입력이므로 크기·형식·IP별 빈도를 제한하고, URL 원문 대신 문서 경로·차단 origin·지시어·브라우저 계열만 집계해 호환성 판단에 필요한 정보만 남긴다.
미디어 파일의 실체와 최종 제공 책임은 계속 파일시스템에 두고, PostgreSQL 카탈로그는 검색·필터·사용처·페이지네이션을 빠르게 제공하는 인덱스로 둔다. `filesystem`을 기본값으로 유지한 뒤 안전 reconcile로 파일을 변경하지 않고 카탈로그와 참조를 채우며, `shadow`에서 두 결과의 개수·용량 차이를 관찰한 후에만 `database`로 전환한다. DB 모드도 reconcile 미실행, 미완료 행, 개수·용량 불일치 또는 조회 오류가 있으면 파일시스템으로 되돌아가므로 카탈로그 장애가 미디어 관리 중단으로 이어지지 않는다. 앱이 관리하는 업로드·생성·변경·삭제와 콘텐츠 참조에는 최선 노력 dual-write를 적용하고, 외부에서 사라진 파일은 reconcile 시 삭제하지 않고 `missing`으로 표시해 운영자가 상태를 확인할 수 있게 한다.
## 2026-07-21 v1.10.87 — 독립 편집 영역은 저장 성공 범위를 서로 침범하지 않는다
사이트 설정은 여러 카드를 동시에 편집할 수 있지만 카드 하나를 저장할 때 폼 전체를 PUT하면, 다른 카드에 아직 저장하지 않은 입력이나 다른 요청이 먼저 반영한 값을 오래된 화면 값으로 되돌릴 수 있다. 각 카드는 자신이 담당하는 필드만 PATCH하고 서버도 전달된 DB 컬럼만 원자적으로 갱신하도록 변경한다. 성공 응답도 해당 카드 필드에만 반영해 화면 안의 다른 미저장 입력을 보존한다.
게시물 초안 자동 저장은 편의를 위한 기능이므로 실패했는데도 이탈을 허용하면 안 된다. 이탈 직전 저장이 실패하거나 이미 저장 중이거나 유효한 payload를 만들 수 없으면 현재 화면에 남기고, 페이지 편집도 같은 미저장 이탈 확인을 사용한다. 저장 요청 중 사용자가 추가로 입력한 페이지 값은 요청에 실제로 보낸 payload와 비교해 남은 변경으로 유지한다.
게시물 HTML 블록은 관리자 입력이라도 Import 파일이나 탈취된 관리자 세션을 통해 실행성 코드가 저장될 수 있다. 정규식으로 위험 문자열 몇 개를 지우는 방식은 깨진 태그, 문자 참조로 우회한 URL, SVG 계열 속성을 HTML parser처럼 해석할 수 없다. 따라서 표·문단·목록·기본 이미지처럼 본문에 필요한 태그와 속성만 허용하는 `sanitize-html` parser를 사용하고, 완전한 HTML 문서를 의도적으로 제공하는 고정 페이지 렌더링과는 정책을 분리한다.
일반 미디어 업로드도 Import와 같은 원칙으로 요청 본문 전체를 메모리에 올리지 않는다. 파일은 업로드 디렉터리 안의 요청별 임시 폴더로 스트리밍하고 파일 수·전체 용량·종류별 용량을 읽는 동안 제한한 뒤, 검증된 파일만 hard link의 배타 생성으로 최종 이름에 옮긴다. 이렇게 하면 같은 이름의 동시 업로드가 기존 파일을 덮어쓰지 않고 실패한 요청의 부분 파일도 한곳에서 정리할 수 있다.
미디어 목록은 디스크 파일과 모든 콘텐츠 사용처를 결합하므로 페이지 번호만 바뀌어도 동일한 전체 스캔을 반복할 이유가 없다. 한 프로세스 안에서 30초 결과 캐시와 진행 중 Promise를 공유하고 파일 변경 API가 캐시를 즉시 무효화한다. Export 작업은 프로세스 메모리 Set만으로 중복을 막지 않고 작업 ID별 PostgreSQL advisory lock을 실행 연결이 끝날 때까지 유지한다. 서버 시작과 30초 주기 worker가 `queued`·중단된 `processing` 작업을 다시 발견하므로 재시작 복구와 다중 인스턴스 단일 실행을 함께 보장한다.
공개 URL의 존재 여부와 화면 상태는 같은 콘텐츠 정책을 따라야 한다. 등록되지 않은 태그는 게시물이 0개인 유효 태그와 다르므로 404로 응답하고, 멤버십 본문을 볼 수 있는 회원은 그 글의 댓글도 같은 권한으로 조회·작성·좋아요할 수 있게 한다. 라우터가 같은 댓글 컴포넌트를 재사용할 때는 slug 변경을 요청 경계로 삼아 이전 입력과 지연 응답을 폐기한다.
어나운스 바 높이는 문구 줄바꿈과 화면 폭에 따라 달라질 수 있으므로 57px 고정값으로 사이드바 위치를 잡을 수 없다. `SiteTopChrome`이 ResizeObserver로 계산한 `--site-top-chrome-height`를 모바일 왼쪽 메뉴와 데스크톱 양쪽 사이드바의 top·뷰포트 높이에 공통 적용한다.
PostgreSQL은 앱 컨테이너가 Docker 내부 네트워크로 접근하므로 호스트 전체 인터페이스에 포트를 공개할 필요가 없다. 로컬 관리 도구 접근은 유지하되 기본 바인딩을 `127.0.0.1`로 제한한다. 마이그레이션은 SQL 파일만 적용되고 이력 INSERT가 실패하거나 그 반대가 되지 않도록 두 작업을 같은 psql 단일 트랜잭션으로 실행한다.
빌드 성공은 실행 코드의 문법과 번들 가능성만 확인할 뿐 보안 정책의 입력·출력은 검증하지 않는다. Node 내장 테스트 러너를 별도 `npm test`로 두고 HTML sanitizer 우회 사례와 multipart 스트리밍 제한부터 회귀 테스트를 시작한다. 브라우저 방어는 기존 사용자 코드·광고 호환성을 깨지 않도록 CSP를 Report-Only로 먼저 관찰하고, `nosniff`, referrer, frame, 권한 정책은 즉시 적용한다. 공개 통계 수집은 정상 브라우저 빈도보다 충분히 높은 IP별 분당 상한을 두어 단일 출처의 DB 쓰기 증폭을 줄인다.
## 2026-07-21 v1.10.86 — Export·Import ZIP은 원본 품질과 서버 메모리를 분리해 다룬다
게시물 Export의 목적은 이미지 품질을 다시 낮추는 것이 아니라 Markdown과 원본 자산을 하나의 이동 가능한 ZIP으로 묶는 것이다. 기존 방식은 모든 자산을 Buffer로 읽고 동기 deflate 결과와 최종 ZIP까지 메모리에 함께 보관해, 설정 가능한 2GB 한도가 실제 서버 메모리 한도로 이어질 수 있었다. Export는 디스크 출력 스트림으로 직접 생성하고 이미 압축된 이미지·영상·첨부 파일은 ZIP `store` 방식으로 담아 원본 바이트를 그대로 유지한다.
Import도 multipart 전체와 압축 해제된 모든 엔트리를 메모리에 보관하지 않는다. 업로드 ZIP은 크기 제한을 적용하며 임시 파일로 스트리밍하고, 중앙 디렉터리의 엔트리 수·개별 크기·총 비압축 크기·압축률을 먼저 검증한다. Markdown은 총 50MB 안에서만 읽고 자산은 필요한 엔트리만 업로드 경로로 스트리밍해 ZIP 폭탄과 메모리 급증을 함께 제한한다.
## 2026-07-21 v1.10.85 — 로그인 세션은 비밀번호와 함께 폐기하고 공개 목록은 요약만 전달한다
서명된 쿠키의 만료 시각만 확인하면 비밀번호를 바꾼 뒤에도 탈취된 기존 세션이 남을 수 있다. 회원 행에 `session_version`을 두고 로그인 토큰에 발급 당시 버전을 포함해, 비밀번호 변경·재설정·관리자 직접 변경 시 버전을 증가시키고 이후 요청에서 DB 값과 비교한다. 배포 시점의 기존 세션은 버전 1로 호환하되 비밀번호가 바뀐 계정의 이전 토큰만 무효화한다.
로그인 실패 제한은 현재 단일 NAS 앱 프로세스 운영을 기준으로 회원·관리자 범위를 분리하고, 계정과 IP를 함께 본다. 계정 원문과 IP 원문은 메모리 키에 남기지 않고 해시하며 15분 동안 계정 5회 또는 IP 20회 실패 시 429를 반환한다. 다중 인스턴스로 확장할 때는 공유 저장소 기반 제한으로 교체한다.
공개 게시물 목록은 화면에 제목·썸네일·요약만 필요하므로 전체 본문을 응답하지 않는다. 서버가 최대 4,000자의 본문 앞부분에서 표시용 요약을 만들고 기본 목록 개수 상한을 항상 적용한다. RSS처럼 본문 기반 요약이 필요한 내부 호출만 명시적으로 본문 조회를 허용한다.
## 2026-07-20 v1.10.81 — AdSense 홈 요약은 API totals와 응답 통화를 기준으로 본다
AdSense Management API의 보고서 응답은 차원 없이 요약을 요청할 때 `rows`보다 `totals`가 실제 합계를 담는 기준이 된다. 관리자 대시보드가 `rows[0]`만 읽으면 공식 AdSense 화면에 수익이 있어도 0으로 보일 수 있으므로, 보고서 합계는 `ReportResult.totals`를 먼저 읽고 필요한 경우에만 첫 row로 보완한다.