diff --git a/.dockerignore b/.dockerignore index 70714c0..88850f0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,12 @@ dist coverage .git .env -.env.development -.env.production +.env.* +!.env.example *.log +**/.DS_Store +public/uploads +tests +test-results +playwright-report +docs diff --git a/components/content/ProseButton.vue b/components/content/ProseButton.vue deleted file mode 100644 index 09284cf..0000000 --- a/components/content/ProseButton.vue +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/components/content/ProseProduct.vue b/components/content/ProseProduct.vue deleted file mode 100644 index d5fc1a3..0000000 --- a/components/content/ProseProduct.vue +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/docs/code-audit.md b/docs/code-audit.md new file mode 100644 index 0000000..6a8fdf4 --- /dev/null +++ b/docs/code-audit.md @@ -0,0 +1,43 @@ +# 코드·구조 점검 — v1.12.31 + +2026-10-21 기준. 저장소 구조와 주요 인증·권한·업로드·미디어·DB·배포 경로를 확인했다. 자동 검증은 운영 DB와 분리한다. NAS Docker 앱의 실제 재배포는 이 점검에서 실행하지 않았다. + +## 폴더와 Compose 판단 + +| 대상 | 처리와 이유 | +| --- | --- | +| `pages`, `layouts`, `components`, `composables` | 유지. Nuxt 화면·공통 UI·상태 로직 구분 | +| `server/api`, `server/routes`, `server/repositories`, `server/utils` | 유지. 공개 API·관리자/파일 경로·DB 조회·서버 공통 처리 구분 | +| `lib` | 유지. 서버와 화면이 공유하는 계산·검증 로직 | +| `tests` | 유지. 단위·API·브라우저 회귀 검증 소스이며 운영 컨테이너에서 실행하지 않음 | +| `test-results`, `playwright-report` | 생성물만 정리. 실패 추적용이며 재실행 시 다시 생성됨 | +| `.nuxt`, `.output`, `node_modules` | 개발·빌드·실행에 사용하는 생성물. Git과 Docker 빌드 입력에서 제외 | +| `db/migrations` | 유지. 신규 설치·기존 DB 갱신·격리 테스트에 필요한 SQL 이력 | +| `public/uploads` | 원본 유지. 실제 콘텐츠이므로 빌드 중복 복사만 제거 | +| `docker-compose.yaml` | 유지. NAS Docker 앱에서 사전 빌드한 로컬 이미지 실행 | +| `docker-compose.build.yml` | 유지. 터미널에서 소스로 이미지 빌드·배포 | +| `docker-compose.test.yml` | 유지. 운영과 분리된 폐기 가능한 테스트 DB | + +## 확인 후 수정한 항목 + +- 호출·동적 렌더링 참조가 없는 `ProseButton.vue`, `ProseProduct.vue` 제거. 관련 현재 명세·파일 지도 정리. +- 테스트 결과물·macOS 메타데이터·빈 API 폴더 정리. 실제 업로드·환경 파일·DB 데이터 보존. +- 내부 `_import-jobs` URL 차단이 디스크 경로 정규화 전 실행되어 인코딩된 구분자와 상위 경로 조합으로 우회되는 문제 수정. 수정 전 격리 API에서 200 응답을 재현하고 차단 회귀 검증 추가. +- `.dockerignore`에 업로드·테스트·문서·모든 실제 환경 파일 제외 추가. Nitro도 업로드를 정적 복사하지 않도록 설정. 로컬 업로드 1,263개, 445,443,599바이트가 기존 `.output/public/uploads`에 중복 복사되던 상태 확인. 원본 파일은 유지. +- 운영 마이그레이션의 SQL 실행과 적용 이력을 파일별 단일 트랜잭션으로 변경. 중간 실패 중단과 절대 환경 파일 경로 검증 추가. +- 기존 미디어 API 실패는 프로필 소유자를 `avatarOwner`와 `usage`에 중복 표시하도록 기대하던 테스트 오류로 확인. 기존 명세에 맞추고 프로필 연결 이미지의 미사용 제외·삭제 차단도 함께 검증. + +## 남은 우선 작업 + +1. **의존성 보안 갱신**: 현재 lockfile의 `npm audit`는 영향 패키지 33개(critical 5, high 23, moderate 5)를 보고한다. 직접·간접 의존성이 함께 집계되며 운영에서 악용 가능한 취약점 33개라는 의미는 아니다. 설치된 Nuxt 3.21.9, Vue SSR 3.5.40, Sharp 0.35.3, sanitize-html 2.17.6 및 `devalue`, 고정한 `brace-expansion` 등은 보안 갱신 대상이다. 개발 도구는 비활성화 상태지만 이것이 다른 패키지의 문제를 해결하지는 않는다. 강제 자동 갱신 대신 지원 버전·의존 관계를 맞추고 전체 회귀 검증을 다시 수행해야 한다. [Nuxt 권고](https://github.com/advisories/GHSA-hxcr-hm88-mpq6), [Vue SSR 권고](https://github.com/advisories/GHSA-g2v6-rqmx-r4w6), [Sharp 권고](https://github.com/advisories/GHSA-wq5f-xc86-pv6w). +2. **큰 파일의 점진적 분리**: `analytics-repository.js`, `content-repository.js`, 관리자 네비게이션·설정 화면 등이 크다. 현재 폴더 수보다 파일 내부의 책임 분리가 유지보수 과제다. 해당 기능 수정 시 조회·편집 영역 단위로 나누며 이번 정리에서 일괄 재구성하지 않는다. +3. **NAS 앱 실기 검증**: GUI 재배포가 사전 빌드한 이미지 ID를 사용하는지 확인 필요. 운영 볼륨·업로드 경로와 프로젝트명은 유지한다. + +## 검증 + +- JavaScript 문법 360개 파일, 단위 290건, API 16건, Chromium E2E 13건 통과. 프로덕션 빌드 통과. +- 셸 구문·Compose 3종 설정 구문·운영 실행/빌드 구성 일치와 테스트 DB 외부 볼륨 미사용 확인. +- 프로덕션 빌드를 임시 업로드와 실행 중인 DB가 없는 주소로 실행해 공개 파일 200·조건부 요청 304 확인. 오류 페이지의 DB 의존을 피한 JSON 요청으로 내부 정규/인코딩 경로 3개 모두 404 확인. +- 빌드 산출물 `.output/public/uploads` 미생성, `.output` 실제 파일 합계 28,243,332바이트 확인. 원본 업로드 1,263개·445,443,599바이트 유지. + +자동 테스트는 격리 DB·임시 업로드만 사용하며 운영 NAS에는 변경을 실행하지 않았다. 결과 상세는 `docs/update.md`의 v1.12.31에 기록한다. 일상 배포 명령은 기존 `docs/deploy.md`를 사용한다. diff --git a/docs/deploy-reference.md b/docs/deploy-reference.md index 5035c8f..34892f8 100644 --- a/docs/deploy-reference.md +++ b/docs/deploy-reference.md @@ -34,6 +34,8 @@ Linux CI 이미지에서 브라우저 시스템 의존성도 함께 준비해야 ### 운영 마이그레이션과 미디어 카탈로그 전환 +`migrate-production-db.sh`는 미적용 SQL 파일 하나와 해당 적용 이력을 같은 트랜잭션으로 실행한다. SQL 실패 시 해당 파일의 변경과 이력을 함께 취소하고 중단하며, 이미 완료된 이전 파일은 유지한다. `ENV_FILE`에는 상대·절대 경로를 사용할 수 있다. + 운영 PostgreSQL 볼륨은 Compose 프로젝트명 변화에 영향받지 않도록 `POSTGRES_VOLUME_NAME` 또는 기본값 `soristudio_sori-studio-postgres`로 실제 이름을 고정하고, 외부 볼륨(`external: true`)으로 선언한다. UGREEN Docker 앱에서 프로젝트 이름이 달라지면 `sori-studio_sori-studio-postgres`처럼 새 빈 볼륨이 붙고 PostgreSQL init 스크립트가 다시 실행될 수 있으므로, 배포 전후 아래 명령으로 DB 컨테이너가 기존 운영 볼륨을 물고 있는지 확인한다. ```bash @@ -603,9 +605,9 @@ Homepage 위젯 연동은 `docs/homepage-widget.md`를 참고한다. Docker에 - 게시물 Export ZIP 산출물은 `public/uploads/exports/YYYY/MM/{jobId}/` 아래 생성되며, 관리자 다운로드 API를 통해 내려받는다. - 완료·실패한 게시물 Export 작업을 관리자 화면에서 삭제하면 연결된 ZIP 파일도 함께 삭제된다. - `public/uploads/`는 Git에 포함하지 않는다. -- NAS 운영에서는 `docker-compose.build.yml`의 `./public/uploads:/app/public/uploads` 볼륨으로 업로드 파일을 유지한다. +- NAS 운영에서는 실행용 `docker-compose.yaml`과 CLI 빌드용 `docker-compose.build.yml`의 `./public/uploads:/app/public/uploads` 볼륨으로 업로드 파일을 유지한다. 업로드는 Docker 빌드 입력과 Nitro 정적 복사에서 제외하므로 새 이미지에 기존 파일을 넣지 않는다. 재배포 시 같은 업로드 폴더를 연결해야 하며 원본 폴더는 삭제하지 않는다. - 운영 빌드에서는 `/uploads/**` 서버 라우트가 `.output/public`이 아니라 `/app/public/uploads` 볼륨의 실제 파일을 직접 제공한다. -- `/uploads/**` 응답은 `ETag`와 `Last-Modified`를 제공한다. 고유 URL로 저장되는 원본·시스템·회원 썸네일 파일은 1년 immutable 캐시를 사용하고, 같은 URL로 재생성될 수 있는 게시물 `thumbs` 파생 파일은 1시간 캐시와 `stale-while-revalidate`를 사용한다. +- `/uploads/**` 응답은 `ETag`와 `Last-Modified`를 제공한다. 고유 URL로 저장되는 원본·시스템·회원 썸네일 파일은 1년 immutable 캐시를 사용하고, 같은 URL로 재생성될 수 있는 게시물 `thumbs` 파생 파일은 `no-cache`로 재검증한다. - `MAX_FILE_SIZE`, `MAX_VIDEO_FILE_SIZE`, `MAX_AUDIO_FILE_SIZE`, `MAX_DOCUMENT_FILE_SIZE` 환경 변수로 관리자 미디어 업로드 파일별 최대 크기를 제한한다. 1회 업로드 최대 파일 수와 전체 용량은 관리자 Settings > POST 설정 > 업로드 제한에서 조절한다. 리버스 프록시(Nginx 등)를 쓰면 `client_max_body_size`가 앱 한도보다 작지 않은지 함께 확인한다. - 관리자 미디어 화면은 현재 업로드 파일 시스템을 기준으로 목록, 파일명 변경, 삭제를 처리한다. diff --git a/docs/history.md b/docs/history.md index 00fe9e7..3fda810 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,3 +1,7 @@ +## 2026-10-21 v1.12.31 — 사용하지 않는 파일과 빌드에 섞인 운영 데이터를 정리한다 + +Nuxt의 화면·컴포넌트·서버·공용 로직 분리는 유지한다. 테스트 소스는 회귀 검증에 사용하고 실행 결과만 삭제한다. Compose 3개는 NAS 앱 실행·CLI 소스 빌드·격리 테스트 DB의 역할이 달라 유지한다. 렌더러·동적 컴포넌트 참조가 없는 버튼·상품 카드 초기 컴포넌트 2개만 제거한다. 실제 업로드는 사용자 데이터이므로 삭제하지 않고 Docker 빌드 입력과 Nitro 정적 복사에서 제외한다. 업로드 내부 경로 차단은 정규화 뒤 검사해 인코딩 구분자와 상위 경로 조합으로 우회하지 못하게 한다. 운영 SQL과 적용 이력은 파일별 같은 트랜잭션에서 처리해 실패 시 부분 적용을 남기지 않는다. 보안 의존성 갱신과 큰 파일 분할은 영향 범위가 다른 후속 작업으로 기록한다. + ## 2026-10-21 v1.12.30 — 긴 접속 시간과 방문 평균을 구분한다 현재 접속자 시간은 30분 이후도 표시하되 슬립으로 오래 멈춘 구간을 포함하지 않도록 페이지 진입부터의 단순 경과 대신 heartbeat 실행 간격을 누적한다. 운영체제 슬립을 직접 판정할 수 없으므로 접속 TTL과 같은 90초 이상 중단 구간을 제외한다. 짧은 중단마다 초기화하지 않도록 30분 미만이면 기존 방문을 유지하고, 30분 이상이면 새 콘텐츠 세션과 페이지뷰로 시작해 이전 접속 시간·읽음 판정을 이어받지 않는다. 별도 폴링 없이 기존 20초 주기를 유지하고 탭 복귀·페이지 복원 때 즉시 갱신한다. 실시간 값만 기존 정수 컬럼 범위로 확장하고 평균 체류의 콘텐츠별 30분 상한은 유지해 오래 열린 탭 때문에 기간 통계가 크게 바뀌지 않게 한다. 개별 접속 시간과 기간 평균은 대상이 다르므로 접속자 목록에는 개별 시간 하나만, 통계에는 기존 평균을 표시한다. diff --git a/docs/map.md b/docs/map.md index fd9f75c..5f5b05a 100644 --- a/docs/map.md +++ b/docs/map.md @@ -212,7 +212,7 @@ | tests/markdown-table.test.js | Markdown 표 행·정렬·블록 파싱 단위 회귀 테스트 | | tests/admin-pagination.test.js | 관리자 페이지네이션 페이지 보정·ellipsis 항목 단위 테스트 | | tests/playwright/e2e/admin-media-interactions.spec.js | 관리자 미디어 전체 선택·선택 유지 폴더 생성·다중 드래그/드롭 강조·이동 진행·취소/실패·목록 지연/최신 요청/재시도 E2E 회귀 검증 | -| tests/playwright/api/core-api.spec.js | 인증·부분 설정 저장·공개 목록·댓글/회원 작성 이력·실시간 활성 탭 집계·긴 접속 시간/평균 체류 상한·CSP·미디어 reconcile/fallback·세션 무효화 API 통합 15건 | +| tests/playwright/api/core-api.spec.js | 인증·부분 설정 저장·공개 목록·댓글/회원 작성 이력·실시간 활성 탭 집계·긴 접속 시간/평균 체류 상한·CSP·내부 업로드 경로 차단·미디어 reconcile/fallback·프로필 이미지 삭제 보호·세션 무효화 API 통합 16건 | | tests/playwright/e2e/admin-guard-and-settings.spec.js | 설정 카드 독립 저장과 미저장 이탈 취소 브라우저 E2E 2건 | | tests/playwright/e2e/admin-publish.spec.js | 관리자 로그인·게시물 발행·공개 화면 확인 브라우저 E2E 1건 | | tests/playwright/e2e/admin-regressions.spec.js | 태그 추천 연속 선택·미디어 파생 탭 배지 숨김·단일 이미지 `{size=}` 너비 브라우저 회귀 테스트 | @@ -247,7 +247,7 @@ | server/utils/security-headers.js | 최소 강제 CSP와 사용자 코드·광고 호환 Report-Only·Reporting API 헤더 구성 | | server/utils/csp-report-request-limit.js | CSP 보고 IP 해시 기준 분당 60회·버킷 상한 제한 | | server/utils/upload-root.js | 일반 `public/uploads`와 Playwright 전용 임시 업로드 루트 분리 | -| server/routes/uploads/[...path].get.js | 런타임 업로드 파일 제공 API(`/app/public/uploads` 볼륨 파일을 `/uploads/**`로 스트리밍, 원본 장기 캐시·파생 이미지 재검증 캐시·조건부 요청 304 지원) | +| server/routes/uploads/[...path].get.js | 런타임 업로드 파일 제공 API(`/app/public/uploads` 볼륨 스트리밍, 정규화 후 내부 가져오기 파일 접근 차단, 원본 장기 캐시·파생 이미지 재검증 캐시·조건부 요청 304 지원) | | server/routes/sitemap.xml.get.js | 공개 sitemap XML(`/sitemap.xml`) | | server/routes/robots.txt.get.js | 공개 robots.txt(`/robots.txt`, sitemap 위치 안내) | | server/routes/rss.xml.get.js | 공개 RSS 2.0 피드(`/rss.xml`) | @@ -347,7 +347,6 @@ | components/content/ProseBlockquote.vue | 인용구, 왼쪽 세로 막대형 기본 스타일과 색상 프리셋, 다크모드 기본 인용 텍스트 가독성 보정 | | components/content/ProseCodeBlock.vue | 코드 블록 공통 셸(다크 배경, 선택 줄번호 gutter, 공개 복사 버튼) | | components/content/ContentMarkdownCodeBlockEditor.vue | 라이브 모드 코드 블록 인라인 편집(Language·줄번호 토글) | -| components/content/ProseButton.vue | 버튼 | | components/content/ContentMarkdownCalloutEditor.vue | 라이브 모드 콜아웃 멀티라인 본문 편집, 본문 인라인 굵게·링크·색상·기본 목록 렌더링, 제목·아이콘 표시 여부·배경 반영, 내부 여러 줄 범위 선택 | | components/content/ProseCallout.vue | Callout 카드(Emoji 표시/숨김, 선택 제목, 인용과 같은 배경 프리셋, 헤더 아래 본문 줄바꿈) | | components/content/ProseToggle.vue | Toggle 카드(펼침 애니메이션, chevron·제목 클릭 트리거) | @@ -355,7 +354,6 @@ | components/content/ProseVideo.vue | `:::video` 공개 비디오 카드 | | components/content/ProseAudio.vue | `:::audio` 공개 오디오 플레이어 카드 | | components/content/ProseFile.vue | `:::file` 공개 다운로드 파일 카드, 제목·파일명이 같으면 용량 중심 보조 정보 표시 | -| components/content/ProseProduct.vue | 상품 카드 | | components/content/ProseBookmark.vue | 본문 북마크 카드(썸네일·도메인·`http(s)` 외부 링크) | | components/content/ProseSignup.vue | 본문 회원가입·뉴스레터 CTA 카드 | | components/content/ProseHeaderCard.vue | 헤더 카드 | @@ -622,7 +620,7 @@ | 파일 | 기능 | |------|------| | package.json | Nuxt 실행·문법 검사·Node 회귀 테스트·전체 verify 스크립트와 의존성 | -| nuxt.config.js | Nuxt 앱 설정, `tailwindcss.cssPath`로 `main.css` 단일 엔트리, Tailwind 모듈, 관리자 QA를 위한 개발 도구 비활성화, 회원 썸네일 최소/최대 해상도·품질 런타임 설정 | +| nuxt.config.js | Nuxt 앱 설정, 런타임 업로드의 Nitro 정적 복사 제외, `tailwindcss.cssPath`로 `main.css` 단일 엔트리, Tailwind 모듈, 관리자 QA를 위한 개발 도구 비활성화, 회원 썸네일 최소/최대 해상도·품질 런타임 설정 | | tailwind.config.js | Tailwind 테마 설정 | | assets/css/main.css | 전역 스타일, 공개 배경·사이드바 배경 통일 기준, 관리자 라이트 테마 격리(`admin-layout`), 좌측 사이드바/그리드 전환 애니메이션, 네비게이션 세로 바 hover 효과, 게시물 카드 제목 공통 인터랙션(`post-title-link`), 이동 중 클릭 잠금(`site-navigating`) | | composables/useMenuState.js | 좌측 메뉴 열림 상태·`closeMenu`(모바일 백드롭 등), 모바일은 마운트 시 항상 닫힘 시작·`menuMounted`로 첫 페인트 슬라이드 플래시 방지 | @@ -652,8 +650,9 @@ | components/admin/AdminRealtimePages.vue | 접속자 최근 페이지 요약·외 N개 색상/숫자 굵기·단일 페이지 링크·다중 페이지 전용 화면 안 팝오버 위치·열린 페이지 링크·hover/클릭/키보드 접근 | | tests/analytics-display.test.js | 천 단위·긴 접속 시간 표시·최근순 중복 제거·공개 경로 검증 | | tests/playwright/e2e/admin-dashboard.spec.js | 대시보드 쉼표·섹션 순서·전체/표시 인원·전체 화면 수·긴 접속 시간·다중 페이지 강조/팝오버·단일 페이지 링크·가상 시계로 짧은 중단/장기 중단 후 새 방문 heartbeat·페이지뷰 검증 | -| tests/production-compose.test.js | 운영 마이그레이션·썸네일 백필 스크립트의 Compose 파일·환경 파일 전달 격리 검증 | +| tests/production-compose.test.js | 운영 스크립트의 Compose·환경 파일 전달, 이미지 빌드 종료 코드, 마이그레이션 SQL/이력 단일 트랜잭션과 실패 중단 격리 검증 | | docker-compose.yaml | NAS Docker 앱용 독립 실행 구성·사전 빌드한 로컬 앱 이미지 사용·앱 원격 pull 금지 | | scripts/build-production-image.sh | 운영 환경 파일을 사용해 앱 이미지만 빌드·Docker 오류 반환·컨테이너/DB 미변경 | | docs/deploy.md | NAS 상황별 복사·실행 명령어: 일반/DB 포함 재배포·앱 재배포 준비·재시작·환경설정·상태·로그 | | docs/deploy-reference.md | 최초 설치·고급 환경설정·개발 환경·장애 대응 상세 참고 | +| docs/code-audit.md | v1.12.31 구조·불필요 파일·업로드·배포·의존성 점검 결과와 후속 과제 | diff --git a/docs/spec.md b/docs/spec.md index 5087f36..d0c433c 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -280,13 +280,11 @@ components/content/ ├── ProseImage.vue # 이미지 (Regular/Wide/Full-width) ├── ProseList.vue # Ordered/Unordered List ├── ProseBlockquote.vue # 인용구 -├── ProseButton.vue # 버튼 (Left-aligned/Centered) ├── ProseCallout.vue # Callout 카드 ├── ProseToggle.vue # Toggle 카드 ├── ProseVideo.vue # Video 카드 ├── ProseAudio.vue # Audio 카드 ├── ProseFile.vue # File 카드 -├── ProseProduct.vue # Product 카드 ├── ProseBookmark.vue # 북마크 카드(썸네일·제목·도메인) ├── ProseSignup.vue # 회원가입/뉴스레터 CTA 카드 ├── ProseHeaderCard.vue # Header 카드 (Simple/Wide/Full-width/Split) @@ -671,7 +669,7 @@ components/content/ ### 업로드 파일 제공 -- `GET /uploads/**` - 런타임 업로드 파일 제공. 운영 Docker에서는 빌드 산출물 `.output/public`이 아니라 `public/uploads` 볼륨의 실제 파일을 읽어 로고·게시물 이미지·회원 썸네일을 즉시 제공한다. 응답은 `ETag`, `Last-Modified`를 제공하고 조건부 요청이 일치하면 304를 반환한다. 원본·시스템·회원 썸네일처럼 새 URL로 생성되는 업로드 파일은 `public, max-age=31536000, immutable`을 사용하며, 같은 URL로 재생성될 수 있는 게시물 `thumbs/*-(card|body|body-720|lightbox).webp` 파생 파일은 `public, max-age=3600, stale-while-revalidate=86400`을 사용한다. +- `GET /uploads/**` - 런타임 업로드 파일 제공. 운영 Docker에서는 `public/uploads` 볼륨의 실제 파일을 읽어 로고·게시물 이미지·회원 썸네일을 즉시 제공한다. 업로드 원본은 Docker 빌드 입력과 Nitro 정적 복사에서 제외해 `.output/public`에 중복 포함하지 않는다. URL 디코딩과 디스크 경로 정규화 뒤 업로드 루트 이탈을 거부하고, 내부 `_import-jobs` 경로는 인코딩된 구분자·상위 경로를 섞어도 404를 반환한다. 응답은 `ETag`, `Last-Modified`를 제공하고 조건부 요청이 일치하면 304를 반환한다. 새 URL로 생성되는 원본·시스템·회원 썸네일은 `public, max-age=31536000, immutable`, 같은 URL로 재생성되는 게시물 `thumbs/*-(card|page-cover|body|body-720|body-1600|body-2560|lightbox).webp`는 `public, no-cache`를 사용한다. ### 관리자 API (`/admin/api/`) diff --git a/docs/todo.md b/docs/todo.md index 68ed516..1c71255 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,10 +1,12 @@ # sori.studio 할 일 -## 1차 관리자 개발 +## 보안 의존성 갱신 -## 2차 관리자 개발 +- v1.12.31 lockfile의 `npm audit` 영향 패키지 33개(critical 5·high 23·moderate 5) 보안 갱신. Nuxt·Vue SSR·Sharp·sanitize-html 및 간접 의존성 devalue·개발 도구, 고정한 brace-expansion 버전을 함께 검토. 개발 전용·운영 포함 여부와 권고별 악용 조건을 구분하고, 지원되는 패치 버전으로 갱신 후 `npm run verify:ci` 및 운영 이미지 빌드 검증. 강제 major 갱신으로 처리하지 않음. 점검 근거는 `docs/code-audit.md` 참조. -- `npm run test:api`의 기존 미디어 카탈로그 회귀 실패 조사: `core-api.spec.js`의 프로필 이미지 `usage`에 `type=user`가 없어 `typeLabel` 확인 실패. 댓글·회원 통합 작성 이력 검색/페이지·실시간 활성 탭 집계 API 검증은 통과. 긴 접속 시간과 평균 체류 상한 분리 API 검증도 통과. v1.12.30 API 검증은 13건 통과·기존 실패 1건·후속 미실행 1건. 격리 DB에서 사용처 계산·카탈로그 캐시 반영 순서 확인. +## 유지보수 + +- 큰 `analytics-repository.js`, `content-repository.js`, 관리자 네비게이션·설정 파일은 해당 기능 수정 때 조회·편집 책임별 점진적 분리 검토. 일괄 재구성하지 않고 API·화면 계약 유지. ## 프론트엔드 개발 @@ -14,11 +16,6 @@ - `recentViolations`는 최근에 관찰된 행의 전체 누적 합계임. 실제 30일 추이가 필요하면 일별 집계·보관 기간·새 관찰 시작점을 설계하며 기존 누적값을 기간별 수치로 변환하지 않음. - 정상 출처와 반복 오탐 확인 후 script/style/img/frame/connect 지시어의 단계별 강제 전환 검토. -## 콘텐츠 스타일 구현 - - -## 데이터베이스 - ## 배포 - UGREEN Docker 앱에서 로컬 이미지 전용 `docker-compose.yaml` 재배포 실기 확인. 이미지 업데이트/다운로드 옵션 유무를 확인하고, 원격 pull 오류가 재발하는지·실행 컨테이너 이미지 ID가 사전 빌드 이미지 ID와 같은지 대조. GUI의 정책 덮어쓰기와 별도 Compose 사본 적용 여부는 현재 미확인. diff --git a/docs/update.md b/docs/update.md index af55713..82bdc58 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,19 @@ # 업데이트 이력 +## v1.12.31 + +- 프로젝트 폴더·배포·테스트·인증·업로드·DB 주요 경로 점검 진행. +- 내부 가져오기 파일의 정규화 경로 우회 차단 및 회귀 검증 추가. +- 업로드·테스트 결과·문서를 운영 이미지 빌드 입력에서 제외하도록 정리. +- 사용하지 않는 본문 컴포넌트 2개 및 임시 생성물 정리. +- 운영 마이그레이션 SQL과 적용 이력의 단일 트랜잭션 처리 보강. +- 프로필 소유자 중복 사용처를 기대하던 기존 테스트 정정 및 미사용 제외·삭제 차단 검증 추가. +- JavaScript 360개 파일 문법·단위 290건·격리 API 16건·Chromium E2E 13건·프로덕션 빌드 통과. +- Compose 3종 구문·운영 구성 일치·테스트 격리·셸 문법 검증 진행. +- 프로덕션 빌드의 런타임 파일 200·조건부 요청 304·내부 경로 JSON 요청 404 검증 진행. +- 업로드 원본 1,263개 보존 및 빌드 산출물의 445,443,599바이트 중복 복사 제거 확인. +- 구조 점검 보고서 추가 및 의존성 보안 갱신·큰 파일 점진 분리 후속 과제 기록. + ## v1.12.30 - 현재 접속자 시간의 30분 표시 제한 해제 및 시간·일 단위 압축 표시 추가. diff --git a/nuxt.config.js b/nuxt.config.js index d922846..4bf0024 100644 --- a/nuxt.config.js +++ b/nuxt.config.js @@ -1,5 +1,9 @@ // https://nuxt.com/docs/api/configuration/nuxt-config import { buildSiteBootInlineScript } from './lib/site-theme-init.js' +import { fileURLToPath } from 'node:url' + +/** @type {string} 정적 빌드 복사에서 제외할 런타임 업로드 경로 */ +const runtimeUploadsGlob = fileURLToPath(new URL('./public/uploads/**', import.meta.url)) export default defineNuxtConfig({ compatibilityDate: '2026-04-29', @@ -9,6 +13,9 @@ export default defineNuxtConfig({ devtools: { enabled: false }, + nitro: { + ignore: [runtimeUploadsGlob] + }, modules: ['./modules/nuxt-ssr-paths-write.mjs', '@nuxtjs/tailwindcss'], /** * 기본 cssPath(assets/css/tailwind.css)가 없으면 패키지의 tailwind.css가 먼저 주입되어 diff --git a/package-lock.json b/package-lock.json index 5fe6528..4aa1c54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.12.30", + "version": "1.12.31", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.12.30", + "version": "1.12.31", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 670a0e9..9283210 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.12.30", + "version": "1.12.31", "private": true, "type": "module", "imports": { diff --git a/scripts/migrate-production-db.sh b/scripts/migrate-production-db.sh index e1ba80c..e55509f 100644 --- a/scripts/migrate-production-db.sh +++ b/scripts/migrate-production-db.sh @@ -46,7 +46,10 @@ load_env_file() { set -a # shellcheck disable=SC1090 - . "./$COMPOSE_ENV_FILE" + case "$COMPOSE_ENV_FILE" in + /*) . "$COMPOSE_ENV_FILE" ;; + *) . "./$COMPOSE_ENV_FILE" ;; + esac set +a } @@ -207,8 +210,8 @@ migrate_database() { fi echo "apply $file_name" - psql_exec -f "$CONTAINER_MIGRATIONS_DIR/$file_name" - run_sql "INSERT INTO $SCHEMA_MIGRATIONS_TABLE (file_name) VALUES ('$file_name') ON CONFLICT (file_name) DO NOTHING;" + psql_exec --single-transaction -f "$CONTAINER_MIGRATIONS_DIR/$file_name" \ + -c "INSERT INTO $SCHEMA_MIGRATIONS_TABLE (file_name) VALUES ('$file_name') ON CONFLICT (file_name) DO NOTHING;" done echo "마이그레이션 실행 완료" diff --git a/server/routes/uploads/[...path].get.js b/server/routes/uploads/[...path].get.js index 1f5d2c5..b88d9ce 100644 --- a/server/routes/uploads/[...path].get.js +++ b/server/routes/uploads/[...path].get.js @@ -82,7 +82,7 @@ const resolveUploadFilePath = (pathname) => { const relativeUrlPath = decodedPath.replace(/^\/uploads\/?/g, '') - if (!relativeUrlPath || relativeUrlPath.includes('\0') || relativeUrlPath === '_import-jobs' || relativeUrlPath.startsWith('_import-jobs/')) { + if (!relativeUrlPath || relativeUrlPath.includes('\0')) { throw createError({ statusCode: 404, message: '업로드 파일을 찾을 수 없습니다.' @@ -99,6 +99,13 @@ const resolveUploadFilePath = (pathname) => { }) } + if (relativeDiskPath === '_import-jobs' || relativeDiskPath.startsWith('_import-jobs/')) { + throw createError({ + statusCode: 404, + message: '업로드 파일을 찾을 수 없습니다.' + }) + } + return filePath } diff --git a/tests/playwright/api/core-api.spec.js b/tests/playwright/api/core-api.spec.js index 6ab9294..2898635 100644 --- a/tests/playwright/api/core-api.spec.js +++ b/tests/playwright/api/core-api.spec.js @@ -464,6 +464,30 @@ test('긴 접속 시간은 실시간에 표시하고 평균 체류 집계 상한 } }) +test('공개 업로드는 내부 가져오기 파일의 정규화된 우회 경로도 차단한다', async ({ request: anonymousApi }) => { + const uploadRoot = process.env.SORI_TEST_UPLOAD_ROOT + const privateDirectory = join(uploadRoot, '_import-jobs', 'path-access-test') + const publicFile = join(uploadRoot, 'path-access-public.txt') + await mkdir(privateDirectory, { recursive: true }) + await writeFile(join(privateDirectory, 'private.txt'), 'private-test-content') + await writeFile(publicFile, 'public-test-content') + try { + const publicResponse = await anonymousApi.get('/uploads/path-access-public.txt') + expect(publicResponse.status()).toBe(200) + expect(await publicResponse.text()).toBe('public-test-content') + for (const path of [ + '/uploads/_import-jobs/path-access-test/private.txt', + '/uploads/folder%2F..%2F_import-jobs/path-access-test/private.txt', + '/uploads/%2F_import-jobs/path-access-test/private.txt' + ]) { + expect((await anonymousApi.get(path)).status(), path).toBe(404) + } + } finally { + await rm(privateDirectory, { recursive: true, force: true }) + await rm(publicFile, { force: true }) + } +}) + test('미디어 카탈로그 reconcile과 DB 목록 계약 및 fallback을 유지한다', async () => { const uploadRoot = process.env.SORI_TEST_UPLOAD_ROOT const mediaDirectory = join(uploadRoot, 'posts', 'catalog-api-test') @@ -556,9 +580,11 @@ test('미디어 카탈로그 reconcile과 DB 목록 계약 및 fallback을 유 const avatarResult = await avatarResponse.json() expect(avatarResult.items[0].avatarOwner.id).toBe(adminUser.userId) expect(avatarResult.items[0].avatarOwner.username).toBeTruthy() - const userUsage = avatarResult.items[0].usage.find((usage) => usage.type === 'user') - expect(userUsage.typeLabel).toBe('회원') - expect(userUsage.adminUrl).toBe(`/admin/members/${adminUser.userId}`) + expect(avatarResult.items[0].usage.some((usage) => usage.type === 'user')).toBe(false) + const unusedAvatars = await adminApi.get('/admin/api/media?paged=1&tab=all&unused=1&search=catalog-owner') + expect((await unusedAvatars.json()).pagination.total).toBe(0) + const deleteAvatarResponse = await adminApi.delete('/admin/api/media', { data: { url: avatarUrl } }) + expect(deleteAvatarResponse.status()).toBe(409) const navigationResponse = await adminApi.get('/admin/api/media?paged=1&search=catalog-02&pageSize=50') const navigationResult = await navigationResponse.json() diff --git a/tests/production-compose.test.js b/tests/production-compose.test.js index 25d65d7..5b5cb29 100644 --- a/tests/production-compose.test.js +++ b/tests/production-compose.test.js @@ -63,3 +63,36 @@ test('운영 이미지 빌드는 읽을 수 없는 환경 파일을 거부한다 rmSync(directory, { recursive: true, force: true }) } }) + +test('운영 마이그레이션은 SQL과 이력을 같은 트랜잭션으로 실행하고 실패하면 중단한다', () => { + const directory = mkdtempSync(join(tmpdir(), 'sori-migration-test-')) + try { + const logPath = join(directory, 'docker.log') + const envPath = join(directory, 'production.env') + writeFileSync(envPath, 'COMPOSE_PROJECT_NAME=sori-studio\n') + writeFileSync(join(directory, 'docker'), `#!/bin/sh +printf "<%s>" "$@" >> "$SORI_TEST_DOCKER_LOG" +printf "\\n" >> "$SORI_TEST_DOCKER_LOG" +for argument in "$@"; do + case "$argument" in + *"to_regclass('public.schema_migrations')"*) printf 't\\n' ;; + --single-transaction) exit 19 ;; + esac +done +`, { mode: 0o755 }) + const result = spawnSync('sh', ['scripts/migrate-production-db.sh', 'migrate'], { + cwd: root, encoding: 'utf8', + env: { ...process.env, PATH: `${directory}:${process.env.PATH}`, ENV_FILE: envPath, SORI_TEST_DOCKER_LOG: logPath } + }) + assert.equal(result.status, 19, result.stderr) + const calls = readFileSync(logPath, 'utf8').trim().split('\n') + const transactions = calls.filter(call => call.includes('<--single-transaction>')) + assert.equal(transactions.length, 1) + assert.ok(transactions[0].startsWith(`<-f><${join(root, 'docker-compose.yaml')}><--env-file><${envPath}>`)) + assert.match(transactions[0], /<-f><\/docker-entrypoint-initdb.d\/001_[^>]+\.sql>/) + assert.match(transactions[0], /<-c>