diff --git a/.env.example b/.env.example index 49db987..9f42d33 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,8 @@ MODERATION_SWEEP_INTERVAL_MS=900000 # 심사 시연 데이터를 준비할 때만 사용하는 계정입니다. 저장소에 실제 비밀번호를 넣지 않습니다. APP_REVIEW_EMAIL= APP_REVIEW_PASSWORD= +# 앱과 공개 약관 및 고객지원 페이지에 함께 표시할 실제 수신 가능한 메일입니다. +SUPPORT_EMAIL= REQUEST_BODY_LIMIT=1mb MOMENT_PHOTO_MAX_FILE_SIZE_MB=10 REVERSE_GEOCODING_BASE_URL=https://nominatim.openstreetmap.org @@ -40,7 +42,7 @@ AUTH_RATE_LIMIT_MAX=10 # emails from the same IP. Looser than the per-account limit above. AUTH_RATE_LIMIT_IP_WINDOW_MS=900000 AUTH_RATE_LIMIT_IP_MAX=40 -# Direct server access must keep this at 0. Set the exact reverse-proxy hop count when used. +# Direct server access must keep this at 0. api.soundlog.p-e.kr의 nginx 뒤에서는 1로 설정합니다. TRUST_PROXY_HOPS=0 UPLOAD_DIRECTORY=uploads UPLOAD_PUBLIC_BASE_URL=http://localhost:4000 @@ -48,8 +50,9 @@ USE_MOCK_DB=false # Production checklist: # NODE_ENV=production -# CLIENT_URLS=https://soundlog.shop,https://www.soundlog.shop -# After API DNS/HTTPS reverse proxy is ready: -# UPLOAD_PUBLIC_BASE_URL=https://api.soundlog.shop +# CLIENT_URLS=https://api.soundlog.p-e.kr +# api.soundlog.p-e.kr의 HTTPS nginx가 준비된 운영 환경: +# UPLOAD_PUBLIC_BASE_URL=https://api.soundlog.p-e.kr +# TRUST_PROXY_HOPS=1 # USE_MOCK_DB=false # ALLOW_DEV_AUTH_FALLBACK=false diff --git a/.github/workflows/deploy-gcp.yml b/.github/workflows/deploy-gcp.yml new file mode 100644 index 0000000..c260a6a --- /dev/null +++ b/.github/workflows/deploy-gcp.yml @@ -0,0 +1,329 @@ +name: Deploy API to production + +on: + workflow_dispatch: + inputs: + image_tag: + description: Docker image tag. Defaults to the selected commit SHA. + required: false + type: string + +concurrency: + group: deploy-api-production + cancel-in-progress: false + +jobs: + verify: + name: Verify release candidate + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + cache: pnpm + node-version: 22 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate Prisma client + run: pnpm run db:generate + + - name: Verify server + run: | + pnpm run typecheck + pnpm run build + pnpm run check:openapi-sync + pnpm run check:no-spotify-metadata + pnpm run test:coverage + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/soundlog_ci?schema=public + JWT_SECRET: soundlog-ci-test-secret + NODE_ENV: test + USE_MOCK_DB: 'true' + + build-and-push: + name: Build and push Docker image + needs: verify + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + image_tag: ${{ steps.meta.outputs.image_tag }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate Docker Hub secrets + shell: bash + env: + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + run: | + set -euo pipefail + for name in DOCKERHUB_USERNAME DOCKERHUB_TOKEN; do + if [ -z "${!name}" ]; then + echo "Missing required secret: $name" + exit 1 + fi + done + + - name: Set image tag + id: meta + shell: bash + run: | + set -euo pipefail + image_tag="${GITHUB_SHA::12}" + if [ -n "${{ inputs.image_tag }}" ]; then + image_tag="${{ inputs.image_tag }}" + fi + if [[ ! "$image_tag" =~ ^[A-Za-z0-9_.-]{1,128}$ ]]; then + echo "Invalid Docker image tag: $image_tag" + exit 1 + fi + echo "image_tag=$image_tag" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/soundlog-server:${{ steps.meta.outputs.image_tag }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + name: Deploy to api.soundlog.p-e.kr + needs: build-and-push + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate deployment secrets and production environment + shell: bash + env: + GCP_APP_DIR: ${{ secrets.GCP_APP_DIR }} + GCP_HOST: ${{ secrets.GCP_HOST }} + GCP_SSH_KEY: ${{ secrets.GCP_SSH_KEY }} + GCP_SSH_PORT: ${{ secrets.GCP_SSH_PORT }} + GCP_USER: ${{ secrets.GCP_USER }} + PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }} + RELEASE_APP_REVIEW_EMAIL: ${{ secrets.APP_REVIEW_EMAIL }} + RELEASE_APP_REVIEW_PASSWORD: ${{ secrets.APP_REVIEW_PASSWORD }} + RELEASE_MODERATION_ADMIN_KEY: ${{ secrets.MODERATION_ADMIN_KEY }} + RELEASE_SUPPORT_EMAIL: ${{ secrets.SUPPORT_EMAIL }} + run: | + set -euo pipefail + missing=() + for name in GCP_HOST GCP_USER GCP_SSH_KEY GCP_SSH_PORT GCP_APP_DIR PRODUCTION_ENV RELEASE_APP_REVIEW_EMAIL RELEASE_APP_REVIEW_PASSWORD RELEASE_MODERATION_ADMIN_KEY RELEASE_SUPPORT_EMAIL; do + if [ -z "${!name}" ]; then + missing+=("$name") + fi + done + + required_env=( + POSTGRES_USER + POSTGRES_PASSWORD + POSTGRES_DB + JWT_SECRET + ) + for key in "${required_env[@]}"; do + value="$(sed -n "s/^${key}=//p" <<< "$PRODUCTION_ENV" | tail -n 1 | tr -d '\r')" + if [ -z "$value" ]; then + missing+=("PRODUCTION_ENV.$key") + fi + done + + if [ "${#missing[@]}" -gt 0 ]; then + printf 'Missing required deployment values: %s\n' "${missing[*]}" + exit 1 + fi + + if grep -qE '^(DOCKER_IMAGE|DATABASE_URL)=' <<< "$PRODUCTION_ENV"; then + echo 'PRODUCTION_ENV must not contain DOCKER_IMAGE or DATABASE_URL.' + exit 1 + fi + + if [ "${#RELEASE_MODERATION_ADMIN_KEY}" -lt 32 ]; then + echo 'MODERATION_ADMIN_KEY must contain at least 32 characters.' + exit 1 + fi + if [ "${#RELEASE_APP_REVIEW_PASSWORD}" -lt 8 ]; then + echo 'APP_REVIEW_PASSWORD must contain at least 8 characters.' + exit 1 + fi + if [[ "$RELEASE_SUPPORT_EMAIL" == *@soundlog.shop ]]; then + echo 'SUPPORT_EMAIL must use a mailbox with verified mail reception.' + exit 1 + fi + + - name: Create production env file + shell: bash + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + IMAGE_TAG: ${{ needs.build-and-push.outputs.image_tag }} + PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }} + RELEASE_APP_REVIEW_EMAIL: ${{ secrets.APP_REVIEW_EMAIL }} + RELEASE_APP_REVIEW_PASSWORD: ${{ secrets.APP_REVIEW_PASSWORD }} + RELEASE_MODERATION_ADMIN_KEY: ${{ secrets.MODERATION_ADMIN_KEY }} + RELEASE_SUPPORT_EMAIL: ${{ secrets.SUPPORT_EMAIL }} + run: | + set -euo pipefail + mkdir -p .deploy + postgres_user="$(sed -n 's/^POSTGRES_USER=//p' <<< "$PRODUCTION_ENV" | tail -n 1 | tr -d '\r')" + postgres_password="$(sed -n 's/^POSTGRES_PASSWORD=//p' <<< "$PRODUCTION_ENV" | tail -n 1 | tr -d '\r')" + postgres_db="$(sed -n 's/^POSTGRES_DB=//p' <<< "$PRODUCTION_ENV" | tail -n 1 | tr -d '\r')" + + database_url="$( + POSTGRES_USER="$postgres_user" POSTGRES_PASSWORD="$postgres_password" POSTGRES_DB="$postgres_db" node <<'NODE' + const encode = encodeURIComponent; + const user = encode(process.env.POSTGRES_USER || ''); + const password = encode(process.env.POSTGRES_PASSWORD || ''); + const database = encode(process.env.POSTGRES_DB || ''); + process.stdout.write(`postgresql://${user}:${password}@db:5432/${database}?schema=public`); + NODE + )" + + { + printf 'DOCKER_IMAGE=%s/soundlog-server:%s\n' "$DOCKERHUB_USERNAME" "$IMAGE_TAG" + printf '%s\n' "$PRODUCTION_ENV" | sed '/^DOCKER_IMAGE=/d;/^DATABASE_URL=/d;/^NODE_ENV=/d;/^CLIENT_URL=/d;/^CLIENT_URLS=/d;/^UPLOAD_PUBLIC_BASE_URL=/d;/^MODERATION_ADMIN_KEY=/d;/^MODERATION_ALERT_MODE=/d;/^APP_REVIEW_EMAIL=/d;/^APP_REVIEW_PASSWORD=/d;/^SUPPORT_EMAIL=/d;/^TRUST_PROXY_HOPS=/d' + printf 'CLIENT_URLS=https://api.soundlog.p-e.kr\n' + printf 'UPLOAD_PUBLIC_BASE_URL=https://api.soundlog.p-e.kr\n' + printf 'MODERATION_ADMIN_KEY=%s\n' "$RELEASE_MODERATION_ADMIN_KEY" + printf 'MODERATION_ALERT_MODE=cloud_logging\n' + printf 'APP_REVIEW_EMAIL=%s\n' "$RELEASE_APP_REVIEW_EMAIL" + printf 'APP_REVIEW_PASSWORD=%s\n' "$RELEASE_APP_REVIEW_PASSWORD" + printf 'SUPPORT_EMAIL=%s\n' "$RELEASE_SUPPORT_EMAIL" + printf 'TRUST_PROXY_HOPS=1\n' + printf 'DATABASE_URL=%s\n' "$database_url" + printf 'NODE_ENV=production\n' + } > .deploy/production.env + + - name: Verify SSH target is the live API host + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.GCP_HOST }} + username: ${{ secrets.GCP_USER }} + key: ${{ secrets.GCP_SSH_KEY }} + port: ${{ secrets.GCP_SSH_PORT }} + script: | + set -eu + resolved_ip="$(getent ahostsv4 api.soundlog.p-e.kr | awk 'NR == 1 { print $1 }')" + public_ip="$(curl -fsS --max-time 10 https://api.ipify.org)" + if [ "$public_ip" != "$resolved_ip" ]; then + echo "Refusing deployment: SSH host $public_ip does not serve api.soundlog.p-e.kr ($resolved_ip)." + exit 1 + fi + app_dir="${{ secrets.GCP_APP_DIR }}" + backup_dir="$app_dir/.deploy-backups/${{ github.run_id }}" + mkdir -p "$app_dir" "$backup_dir" + [ ! -f "$app_dir/.env" ] || cp -p "$app_dir/.env" "$backup_dir/.env" + [ ! -f "$app_dir/docker-compose.prod.yml" ] || cp -p "$app_dir/docker-compose.prod.yml" "$backup_dir/docker-compose.prod.yml" + sudo docker --version + sudo docker compose version + + - name: Copy production compose file + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.GCP_HOST }} + username: ${{ secrets.GCP_USER }} + key: ${{ secrets.GCP_SSH_KEY }} + port: ${{ secrets.GCP_SSH_PORT }} + source: docker-compose.prod.yml + target: ${{ secrets.GCP_APP_DIR }} + overwrite: true + + - name: Copy production env file + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.GCP_HOST }} + username: ${{ secrets.GCP_USER }} + key: ${{ secrets.GCP_SSH_KEY }} + port: ${{ secrets.GCP_SSH_PORT }} + source: .deploy/production.env + target: ${{ secrets.GCP_APP_DIR }} + strip_components: 1 + overwrite: true + + - name: Deploy with rollback backup + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.GCP_HOST }} + username: ${{ secrets.GCP_USER }} + key: ${{ secrets.GCP_SSH_KEY }} + port: ${{ secrets.GCP_SSH_PORT }} + command_timeout: 15m + script: | + set -eu + cd "${{ secrets.GCP_APP_DIR }}" + backup_dir=".deploy-backups/${{ github.run_id }}" + mv production.env .env + + rollback() { + echo 'Deployment failed. Restoring the previous compose and environment files.' + [ ! -f "$backup_dir/.env" ] || cp -p "$backup_dir/.env" .env + [ ! -f "$backup_dir/docker-compose.prod.yml" ] || cp -p "$backup_dir/docker-compose.prod.yml" docker-compose.prod.yml + sudo docker compose -f docker-compose.prod.yml up -d db api || true + } + trap rollback INT TERM HUP EXIT + + echo "${{ secrets.DOCKERHUB_TOKEN }}" | sudo docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin + sudo docker compose -f docker-compose.prod.yml pull api + sudo docker compose -f docker-compose.prod.yml up -d db api + + for attempt in $(seq 1 24); do + if sudo docker compose -f docker-compose.prod.yml exec -T api \ + node -e "fetch('http://127.0.0.1:4000/v1/health').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"; then + break + fi + if [ "$attempt" -eq 24 ]; then + sudo docker compose -f docker-compose.prod.yml logs --tail=160 api + exit 1 + fi + sleep 10 + done + + sudo docker compose -f docker-compose.prod.yml exec -T api node scripts/check-production-env.mjs + sudo docker compose -f docker-compose.prod.yml exec -T api node dist/prisma/seed-review.js + sudo docker compose -f docker-compose.prod.yml exec -T \ + -e PUBLIC_API_BASE_URL=http://127.0.0.1:4000 \ + api node scripts/check-public-api-contract.mjs + + trap - INT TERM HUP EXIT + echo "Deployment completed. Rollback backup: $backup_dir" + + - name: Verify public HTTPS endpoints + shell: bash + env: + PUBLIC_API_BASE_URL: https://api.soundlog.p-e.kr + run: | + set -euo pipefail + node scripts/check-public-api-contract.mjs + for endpoint in /legal/privacy /legal/terms /support; do + curl --fail --silent --show-error --head --max-time 15 "${PUBLIC_API_BASE_URL}${endpoint}" > /dev/null + done + admin_status="$(curl --silent --output /dev/null --write-out '%{http_code}' --max-time 15 "${PUBLIC_API_BASE_URL}/v1/admin/moderation/reports")" + if [ "$admin_status" != '401' ]; then + echo "Expected unauthenticated moderation API to return 401, got $admin_status." + exit 1 + fi diff --git a/README.md b/README.md index 98369dc..295c9f3 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Soundlog는 웹 서비스를 배포하지 않습니다. 배포된 iOS와 Android - Swagger UI: `http://localhost:4000/docs` - OpenAPI YAML: `http://localhost:4000/openapi.yaml` -- 운영 API: `https://api.soundlog.shop` +- 운영 API: `https://api.soundlog.p-e.kr` Swagger에서 바로 DB 쓰기를 확인할 때는 인증 없이 호출 가능한 개발용 API를 사용할 수 있습니다. @@ -99,7 +99,7 @@ Swagger에서 바로 DB 쓰기를 확인할 때는 인증 없이 호출 가능 - `ALLOW_DEV_AUTH_FALLBACK=false` - 자체 이메일/비밀번호 로그인만 사용하며, 서버는 비밀번호 원문 대신 bcrypt hash만 저장 - `CLIENT_URLS`, `UPLOAD_PUBLIC_BASE_URL`, 앱의 `EXPO_PUBLIC_SOUNDLOG_API_BASE_URL`은 HTTPS 도메인 사용 -- 운영 클라이언트는 네이티브 앱입니다. 공개 API URL은 `https://api.soundlog.shop`입니다. +- 운영 클라이언트는 네이티브 앱입니다. 공개 API URL은 `https://api.soundlog.p-e.kr`입니다. - `REQUEST_BODY_LIMIT`, `MOMENT_PHOTO_MAX_FILE_SIZE_MB`, `UPLOAD_DIRECTORY`는 운영 파일 업로드 정책에 맞게 조정 - iOS 앱 설정에 전체 ATS 예외를 넣지 않기 @@ -111,6 +111,8 @@ Swagger에서 바로 DB 쓰기를 확인할 때는 인증 없이 호출 가능 NODE_ENV=production npm run check:production-env ``` +현재 운영 서버의 수동 배포 절차와 GitHub Actions 시크릿 구성은 [운영 배포 안내](docs/production-deployment.md)를 따릅니다. + ## Scripts ```bash diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 257b17b..42874ac 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -36,6 +36,7 @@ services: MODERATION_SWEEP_INTERVAL_MS: ${MODERATION_SWEEP_INTERVAL_MS:-900000} APP_REVIEW_EMAIL: ${APP_REVIEW_EMAIL} APP_REVIEW_PASSWORD: ${APP_REVIEW_PASSWORD} + SUPPORT_EMAIL: ${SUPPORT_EMAIL} REQUEST_BODY_LIMIT: ${REQUEST_BODY_LIMIT:-1mb} MOMENT_PHOTO_MAX_FILE_SIZE_MB: ${MOMENT_PHOTO_MAX_FILE_SIZE_MB:-10} REVERSE_GEOCODING_BASE_URL: ${REVERSE_GEOCODING_BASE_URL:-https://nominatim.openstreetmap.org} diff --git a/docs/moderation-operations.md b/docs/moderation-operations.md index e42ffdc..6d9ac8e 100644 --- a/docs/moderation-operations.md +++ b/docs/moderation-operations.md @@ -19,7 +19,7 @@ pnpm db:seed:review 아래 예시에서 주소와 비밀키는 운영 환경에 맞게 바꿉니다. ```bash -curl -fsS 'https://api.soundlog.shop/v1/admin/moderation/reports?status=pending&limit=50' \ +curl -fsS 'https://api.soundlog.p-e.kr/v1/admin/moderation/reports?status=pending&limit=50' \ -H 'x-soundlog-admin-key: YOUR_ADMIN_KEY' ``` @@ -30,7 +30,7 @@ curl -fsS 'https://api.soundlog.shop/v1/admin/moderation/reports?status=pending& - `hide_and_suspend`는 콘텐츠를 숨기고 작성자의 로그인을 중단합니다. ```bash -curl -fsS -X PATCH 'https://api.soundlog.shop/v1/admin/moderation/reports/REPORT_ID' \ +curl -fsS -X PATCH 'https://api.soundlog.p-e.kr/v1/admin/moderation/reports/REPORT_ID' \ -H 'content-type: application/json' \ -H 'x-soundlog-admin-key: YOUR_ADMIN_KEY' \ --data '{"action":"hide_content","note":"운영 정책 위반 콘텐츠를 숨겼습니다."}' @@ -41,20 +41,20 @@ curl -fsS -X PATCH 'https://api.soundlog.shop/v1/admin/moderation/reports/REPORT 사진이 포함된 공개 리캡과 음악 기록은 승인 전까지 다른 사용자에게 보이지 않습니다. ```bash -curl -fsS 'https://api.soundlog.shop/v1/admin/moderation/content?limit=50' \ +curl -fsS 'https://api.soundlog.p-e.kr/v1/admin/moderation/content?limit=50' \ -H 'x-soundlog-admin-key: YOUR_ADMIN_KEY' ``` 응답의 `photoUrl` 또는 `backgroundImageUrl` 마지막 경로에 있는 32자리 `fileId`를 관리자 이미지 경로에 넣으면 사용자 로그인 토큰 없이 검토 원본을 확인할 수 있습니다. ```bash -curl -fsS 'https://api.soundlog.shop/v1/admin/moderation/content-images/FILE_ID' \ +curl -fsS 'https://api.soundlog.p-e.kr/v1/admin/moderation/content-images/FILE_ID' \ -H 'x-soundlog-admin-key: YOUR_ADMIN_KEY' \ --output moderation-image ``` ```bash -curl -fsS -X PATCH 'https://api.soundlog.shop/v1/admin/moderation/content/CONTENT_ID' \ +curl -fsS -X PATCH 'https://api.soundlog.p-e.kr/v1/admin/moderation/content/CONTENT_ID' \ -H 'content-type: application/json' \ -H 'x-soundlog-admin-key: YOUR_ADMIN_KEY' \ --data '{"type":"recap","decision":"approved"}' @@ -65,7 +65,7 @@ curl -fsS -X PATCH 'https://api.soundlog.shop/v1/admin/moderation/content/CONTEN 서버는 15분마다 처리 기한을 확인합니다. 20시간이 지난 미처리 신고와 24시간을 넘긴 신고를 웹훅으로 다시 알립니다. 배포 직후에는 아래 명령으로 점검 작업을 직접 실행하고 웹훅 수신 여부를 확인합니다. ```bash -curl -fsS -X POST 'https://api.soundlog.shop/v1/admin/moderation/sweep' \ +curl -fsS -X POST 'https://api.soundlog.p-e.kr/v1/admin/moderation/sweep' \ -H 'x-soundlog-admin-key: YOUR_ADMIN_KEY' ``` diff --git a/docs/production-deployment.md b/docs/production-deployment.md new file mode 100644 index 0000000..df7c23c --- /dev/null +++ b/docs/production-deployment.md @@ -0,0 +1,71 @@ +# Soundlog 운영 배포 안내 + +이 문서는 `https://api.soundlog.p-e.kr` 운영 API를 안전하게 배포하고 앱 심사 전에 동작을 확인하는 절차를 설명합니다. 운영 앞단은 nginx가 HTTPS를 처리하고 API 컨테이너는 외부에 4000 포트를 직접 공개하지 않습니다. + +## 배포 실행 방식 + +GitHub Actions의 `Deploy API to production` 워크플로를 수동으로 실행합니다. `main` 브랜치 푸시만으로 자동 배포하지 않습니다. 실행하면 서버 검증과 Docker 이미지 생성을 마친 뒤 운영 서버의 공인 IP가 `api.soundlog.p-e.kr`의 DNS 주소와 같은지 확인합니다. 주소가 다르면 이전 서버에 잘못 배포하지 않도록 즉시 중단합니다. + +워크플로 파일은 `.github/workflows/deploy-gcp.yml`입니다. 파일명과 `GCP_` 접두사는 기존 설정과의 호환을 위해 유지하지만 각 시크릿은 현재 `api.soundlog.p-e.kr` 서버를 가리켜야 합니다. + +## GitHub Actions 시크릿 + +다음 Repository secret이 필요합니다. + +- `DOCKERHUB_USERNAME`에는 운영 이미지를 올릴 Docker Hub 사용자명을 넣습니다. +- `DOCKERHUB_TOKEN`에는 이미지 push와 운영 서버 pull에 사용할 토큰을 넣습니다. +- `GCP_HOST`에는 현재 운영 서버 주소를 넣습니다. +- `GCP_USER`에는 현재 운영 서버 SSH 사용자를 넣습니다. +- `GCP_SSH_PORT`에는 SSH 포트를 넣습니다. +- `GCP_SSH_KEY`에는 해당 서버에 접속할 개인키를 넣습니다. +- `GCP_APP_DIR`에는 서버에서 compose 파일과 `.env`를 관리할 절대 경로를 넣습니다. +- `PRODUCTION_ENV`에는 DB와 API의 기본 운영 환경변수를 여러 줄 형식으로 넣습니다. +- `MODERATION_ADMIN_KEY`에는 32자 이상의 신고 운영 API 비밀키를 넣습니다. +- `APP_REVIEW_EMAIL`과 `APP_REVIEW_PASSWORD`에는 Apple 심사 전용 로그인 정보를 넣습니다. +- `SUPPORT_EMAIL`에는 약관과 지원 페이지에 공개할 실제 수신 가능한 메일 주소를 넣습니다. + +시크릿 값은 저장소 파일이나 Pull Request 본문에 기록하지 않습니다. + +## PRODUCTION_ENV 필수값 + +`PRODUCTION_ENV`에는 최소한 다음 값을 포함해야 합니다. + +```dotenv +POSTGRES_USER= +POSTGRES_PASSWORD= +POSTGRES_DB= +JWT_SECRET= +USE_MOCK_DB=false +ALLOW_DEV_AUTH_FALLBACK=false +``` + +심사 운영값은 각각 별도 Repository secret으로 관리합니다. 워크플로가 기존 `PRODUCTION_ENV`에서 같은 이름의 오래된 값을 제거하고 별도 시크릿을 최종 환경 파일에 덧붙입니다. 신고 알림 방식은 `cloud_logging`으로 설정하고 nginx 프록시 단계는 `1`로 고정합니다. + +`SUPPORT_EMAIL`에는 실제로 메일을 받을 수 있고 심사 대응에 사용할 주소를 넣습니다. 수신 설정이 확인되지 않은 `@soundlog.shop` 주소는 운영 검사에서 거부합니다. `DOCKER_IMAGE`, `DATABASE_URL`, `NODE_ENV`, `CLIENT_URL`, `CLIENT_URLS`, `UPLOAD_PUBLIC_BASE_URL`, `MODERATION_ALERT_MODE`, `TRUST_PROXY_HOPS`는 워크플로가 안전하게 생성하므로 `PRODUCTION_ENV`에 직접 넣지 않습니다. 클라이언트와 업로드 공개 주소는 `https://api.soundlog.p-e.kr`로 고정됩니다. + +`ML_RECOMMENDATION_API_URL`은 API 서버가 내부적으로 호출할 HTTPS 추천 엔드포인트입니다. 앱은 이 내부 주소를 직접 호출하지 않고 `https://api.soundlog.p-e.kr/v1/recommendations/playlists`만 호출합니다. 배포 후 공개 계약 검사는 이 경로가 폴백이 아닌 `ml-recommendation` 결과와 HTTPS 커버 이미지를 반환하는지 확인합니다. + +## 배포 중 보호 절차 + +워크플로는 새 compose 파일을 복사하기 전에 기존 `.env`와 `docker-compose.prod.yml`을 `.deploy-backups/`에 보관합니다. 새 API가 시작되지 않거나 내부 계약 검사가 실패하면 이전 파일로 되돌리고 기존 컨테이너를 다시 시작합니다. + +새 컨테이너가 정상 상태가 되면 운영 환경 검사와 Prisma migration을 확인합니다. 이어서 앱 심사용 계정과 시연 데이터를 멱등 방식으로 준비하고 내부 API 계약을 검사합니다. 마지막에는 공개 HTTPS 주소에서 상태와 OpenAPI와 법적 문서와 관리자 인증 경계를 확인합니다. + +## 배포 후 확인 + +배포 성공 뒤 아래 주소가 모두 기대한 상태인지 확인합니다. + +```bash +curl -fsS https://api.soundlog.p-e.kr/v1/health +curl -fsS https://api.soundlog.p-e.kr/openapi.yaml > /dev/null +curl -fsS https://api.soundlog.p-e.kr/legal/privacy > /dev/null +curl -fsS https://api.soundlog.p-e.kr/legal/terms > /dev/null +curl -fsS https://api.soundlog.p-e.kr/support > /dev/null +PUBLIC_API_BASE_URL=https://api.soundlog.p-e.kr node scripts/check-public-api-contract.mjs +``` + +관리자 키가 없는 요청으로 `/v1/admin/moderation/reports`를 호출하면 `401`이어야 합니다. `404`라면 운영 서버가 아직 관리자 신고 API가 포함된 최신 버전이 아닙니다. + +## 앱 심사 전 추가 확인 + +심사용 계정으로 iOS 시뮬레이터 또는 TestFlight 앱에 로그인합니다. 공개 리캡 신고와 사용자 차단을 각각 실행하고 차단된 콘텐츠가 피드와 지도에서 즉시 숨겨지는지 확인합니다. 운영 담당자는 관리자 API에서 신고가 접수됐는지 확인하고 24시간 대응 절차를 `docs/moderation-operations.md`에 따라 점검합니다. diff --git a/openapi/soundlog-api.yaml b/openapi/soundlog-api.yaml index c04ae26..d496bce 100644 --- a/openapi/soundlog-api.yaml +++ b/openapi/soundlog-api.yaml @@ -15,7 +15,7 @@ info: servers: - url: http://localhost:4000 description: Local Docker/API server - - url: https://api.soundlog.shop + - url: https://api.soundlog.p-e.kr description: Production API tags: - name: System diff --git a/scripts/check-production-env.mjs b/scripts/check-production-env.mjs index acf7886..739ca6e 100644 --- a/scripts/check-production-env.mjs +++ b/scripts/check-production-env.mjs @@ -9,6 +9,8 @@ const required = [ 'MODERATION_ALERT_MODE', 'APP_REVIEW_EMAIL', 'APP_REVIEW_PASSWORD', + 'SUPPORT_EMAIL', + 'TRUST_PROXY_HOPS', ]; const errors = []; @@ -48,10 +50,18 @@ if (process.env.AUTH_RATE_LIMIT_ENABLED === 'false') { addError('AUTH_RATE_LIMIT_ENABLED must not be false in production.'); } +if (process.env.TRUST_PROXY_HOPS !== '1') { + addError('TRUST_PROXY_HOPS must be 1 behind the api.soundlog.p-e.kr nginx proxy.'); +} + if (!isHttpsUrl(process.env.UPLOAD_PUBLIC_BASE_URL)) { addError('UPLOAD_PUBLIC_BASE_URL must be an HTTPS URL.'); } +if (process.env.UPLOAD_PUBLIC_BASE_URL !== 'https://api.soundlog.p-e.kr') { + addError('UPLOAD_PUBLIC_BASE_URL must use https://api.soundlog.p-e.kr.'); +} + if ( process.env.ML_RECOMMENDATION_API_URL && !isHttpsUrl(process.env.ML_RECOMMENDATION_API_URL) @@ -76,6 +86,10 @@ clientUrls.forEach((url) => { } }); +if (clientUrls.length !== 1 || clientUrls[0] !== 'https://api.soundlog.p-e.kr') { + addError('CLIENT_URLS must use only https://api.soundlog.p-e.kr in production.'); +} + if ((process.env.JWT_SECRET ?? '').length < 32) { addWarning('JWT_SECRET is shorter than 32 characters. Use a long random secret in production.'); } @@ -99,6 +113,14 @@ if ((process.env.APP_REVIEW_PASSWORD ?? '').length < 8) { addError('APP_REVIEW_PASSWORD must be at least 8 characters.'); } +if (!/^\S+@\S+\.\S+$/.test(process.env.SUPPORT_EMAIL ?? '')) { + addError('SUPPORT_EMAIL must be a valid email address.'); +} + +if ((process.env.SUPPORT_EMAIL ?? '').endsWith('@soundlog.shop')) { + addError('SUPPORT_EMAIL must not use soundlog.shop until its mail receiving setup is verified.'); +} + if (!process.env.TOUR_API_SERVICE_KEY) { addWarning('TOUR_API_SERVICE_KEY is missing. Tour nearby-place API will fall back to seed/mock behavior.'); } diff --git a/scripts/check-public-api-contract.mjs b/scripts/check-public-api-contract.mjs index c8d3648..c4b2af6 100644 --- a/scripts/check-public-api-contract.mjs +++ b/scripts/check-public-api-contract.mjs @@ -8,7 +8,7 @@ let ownsContractUser = false; if (!apiBaseUrl) { console.error( - 'Usage: PUBLIC_API_BASE_URL=https://soundlog.shop/api/soundlog node scripts/check-public-api-contract.mjs', + 'Usage: PUBLIC_API_BASE_URL=https://api.soundlog.p-e.kr node scripts/check-public-api-contract.mjs', ); process.exit(1); } @@ -130,7 +130,16 @@ async function verifyOpenApi() { addError(`/openapi.yaml returned HTTP ${response.status}.`); } - if (!text.includes('openapi: 3.1.0') || !text.includes('/v1/auth/register')) { + const requiredPaths = [ + '/v1/auth/register:', + '/v1/community/blocks:', + '/v1/admin/moderation/reports:', + ]; + + if ( + !text.includes('openapi: 3.1.0') || + requiredPaths.some((path) => !text.includes(path)) + ) { addError('/openapi.yaml does not match the current SoundLogServer API contract.'); } } catch (error) { @@ -138,6 +147,47 @@ async function verifyOpenApi() { } } +async function verifyLegalPages() { + const pages = [ + ['/legal/privacy', '개인정보 처리방침'], + ['/legal/terms', '서비스 이용약관'], + ['/support', '고객지원'], + ]; + + for (const [path, title] of pages) { + try { + const { response, text } = await fetchText(path); + + if (!response.ok) { + addError(`${path} returned HTTP ${response.status}.`); + continue; + } + if (!response.headers.get('content-type')?.includes('text/html')) { + addError(`${path} did not return an HTML document.`); + } + if (!text.includes(`

${title}

`) || !text.includes('mailto:')) { + addError(`${path} does not contain the expected public support content.`); + } + } catch (error) { + addError(error instanceof Error ? error.message : String(error)); + } + } +} + +async function verifyModerationAuthBoundary() { + try { + const { response, text } = await fetchText('/v1/admin/moderation/reports'); + + if (response.status !== 401) { + addError( + `/v1/admin/moderation/reports returned HTTP ${response.status}; expected 401 without the admin key. sample=${text.slice(0, 120)}`, + ); + } + } catch (error) { + addError(error instanceof Error ? error.message : String(error)); + } +} + async function verifyNearbyPlaces() { try { const payload = await fetchJson( @@ -188,6 +238,34 @@ async function verifyMusicMetadata() { } } +async function verifyMlRecommendation() { + try { + const payload = await fetchJson( + '/v1/recommendations/playlists?mood=%EC%9E%94%EC%9E%94%ED%95%9C&state=%EB%B0%94%EB%8B%A4&x=129.1186&y=35.1532', + { authenticated: true }, + ); + const recommendation = payload?.data; + + if (recommendation?.context?.source !== 'ml-recommendation') { + addError( + `/v1/recommendations/playlists did not return the ML source: ${JSON.stringify( + recommendation?.context ?? null, + )}`, + ); + } + + if (!Array.isArray(recommendation?.tracks) || recommendation.tracks.length === 0) { + addError('/v1/recommendations/playlists returned no recommended tracks.'); + } + + if (!recommendation?.coverImageUrl?.startsWith('https://')) { + addError('/v1/recommendations/playlists returned no HTTPS playlist cover image.'); + } + } catch (error) { + addError(error instanceof Error ? error.message : String(error)); + } +} + async function verifyPlaylistCatalog() { try { const payload = await fetchJson('/v1/playlists/jeju-island', { @@ -202,7 +280,7 @@ async function verifyPlaylistCatalog() { if (typeof playlist?.backgroundImageUrl !== 'string') { addError('/v1/playlists/jeju-island did not return a background image URL.'); } else { - const artworkPath = new URL(playlist.backgroundImageUrl).pathname; + const artworkPath = new URL(playlist.backgroundImageUrl, `${apiBaseUrl}/`).pathname; const { response } = await fetchText(artworkPath); if (!response.ok || response.headers.get('content-type') !== 'image/webp') { @@ -258,11 +336,14 @@ async function verifyRemovedMusicPlatformRoute() { await verifyHealth(); await verifyOpenApi(); +await verifyLegalPages(); +await verifyModerationAuthBoundary(); await createContractSession(); try { await verifyNearbyPlaces(); await verifyMusicMetadata(); + await verifyMlRecommendation(); await verifyPlaylistCatalog(); await verifyRemovedMusicPlatformRoute(); } finally { diff --git a/src/config/env.ts b/src/config/env.ts index aee605a..1740b42 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -41,6 +41,7 @@ const envSchema = z.object({ .string() .min(8) .default('Soundlog/0.1 (+https://github.com/SoundLogTeam/SoundLogServer)'), + SUPPORT_EMAIL: z.string().email().default('support@soundlog.shop'), TOUR_API_BASE_URL: z.string().url().default('https://apis.data.go.kr/B551011/KorService2'), TOUR_API_SERVICE_KEY: z.string().optional(), TRUST_PROXY_HOPS: z.coerce.number().int().min(0).default(0), diff --git a/src/routes/legal.router.ts b/src/routes/legal.router.ts index f9c87bc..ed021bd 100644 --- a/src/routes/legal.router.ts +++ b/src/routes/legal.router.ts @@ -1,6 +1,8 @@ import { Router } from 'express'; -const SUPPORT_EMAIL = 'support@soundlog.shop'; +import { env } from '../config/env.js'; + +const SUPPORT_EMAIL = env.SUPPORT_EMAIL; const EFFECTIVE_DATE = '2026.08.15'; type LegalSection = { diff --git a/tests/legal-pages.test.ts b/tests/legal-pages.test.ts index 4cbaeaf..039c60f 100644 --- a/tests/legal-pages.test.ts +++ b/tests/legal-pages.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import { createApp } from '../src/app.js'; const app = createApp(); +const expectedSupportEmail = process.env.SUPPORT_EMAIL ?? 'support@soundlog.shop'; describe('public legal pages', () => { it.each([ @@ -18,7 +19,7 @@ describe('public legal pages', () => { expect(response.headers['content-type']).toContain('text/html'); expect(response.headers['cache-control']).toBe('public, max-age=300'); expect(response.text).toContain(`

${title}

`); - expect(response.text).toContain('support@soundlog.shop'); + expect(response.text).toContain(expectedSupportEmail); expect(headResponse.status).toBe(200); expect(headResponse.headers['content-type']).toContain('text/html'); });