diff --git a/.env.example b/.env.example index a1bbc09..aa46776 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,8 @@ POSTGRES_PASSWORD=soundlog_password POSTGRES_DB=soundlog JWT_SECRET=change-me-in-local-development JWT_EXPIRES_IN_SECONDS=3600 -ML_RECOMMENDATION_API_URL=http://211.188.54.204:8000/recommend +# Optional. Production accepts HTTPS endpoints only; otherwise seed recommendations are used. +ML_RECOMMENDATION_API_URL= ML_RECOMMENDATION_TIMEOUT_MS=5000 REQUEST_BODY_LIMIT=1mb MOMENT_PHOTO_MAX_FILE_SIZE_MB=10 @@ -19,9 +20,19 @@ REVERSE_GEOCODING_USER_AGENT=Soundlog/0.1 (+https://github.com/SoundLogTeam/Soun TOUR_API_BASE_URL=https://apis.data.go.kr/B551011/KorService2 TOUR_API_SERVICE_KEY= ALLOW_DEV_AUTH_FALLBACK=false +# Rate limiting for /v1/auth/login, /v1/auth/register, /v1/auth/refresh. +# AUTH_RATE_LIMIT_ENABLED defaults to true, except under NODE_ENV=test where +# it defaults to false so test suites can call auth endpoints repeatedly. +# AUTH_RATE_LIMIT_ENABLED=true +# Per-account limit (keyed by IP + email): blocks repeated attempts against one account. +AUTH_RATE_LIMIT_WINDOW_MS=900000 +AUTH_RATE_LIMIT_MAX=10 +# Per-IP limit (keyed by IP only): blocks credential stuffing across many different +# 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 UPLOAD_DIRECTORY=uploads UPLOAD_PUBLIC_BASE_URL=http://localhost:4000 -UPLOAD_PUBLIC_PATH=/uploads USE_MOCK_DB=false # Production checklist: @@ -29,6 +40,5 @@ USE_MOCK_DB=false # 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 -# UPLOAD_PUBLIC_PATH=/uploads # USE_MOCK_DB=false # ALLOW_DEV_AUTH_FALLBACK=false diff --git a/.github/workflows/deploy-ec2.yml b/.github/workflows/deploy-ec2.yml deleted file mode 100644 index a1b02ee..0000000 --- a/.github/workflows/deploy-ec2.yml +++ /dev/null @@ -1,484 +0,0 @@ -name: Deploy API to EC2 - -on: - push: - branches: - - main - - codex/server-deploy-contract-check-fix - workflow_dispatch: - inputs: - image_tag: - description: Docker image tag to build and deploy. Defaults to the current commit SHA. - required: false - type: string - aws_region: - description: Optional AWS region used to open the EC2 API ingress rule. - required: false - type: string - security_group_id: - description: Optional EC2 security group ID used to open the API ingress rule. - required: false - type: string - -concurrency: - group: deploy-api-${{ github.ref }} - cancel-in-progress: true - -jobs: - build-and-push: - name: Build and push Docker image - 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_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} - run: | - set -euo pipefail - missing=() - for name in DOCKERHUB_USERNAME DOCKERHUB_TOKEN; do - if [ -z "${!name}" ]; then - missing+=("$name") - fi - done - - if [ "${#missing[@]}" -gt 0 ]; then - printf 'Missing required secrets: %s\n' "${missing[*]}" - exit 1 - fi - - - name: Set image tag - id: meta - shell: bash - run: | - set -euo pipefail - image_tag="${GITHUB_SHA::12}" - - if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -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 }} - ${{ secrets.DOCKERHUB_USERNAME }}/soundlog-server:latest - cache-from: type=gha - cache-to: type=gha,mode=max - - deploy: - name: Pull and restart on EC2 - runs-on: ubuntu-latest - needs: build-and-push - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Validate deployment secrets - shell: bash - env: - EC2_HOST: ${{ secrets.EC2_HOST }} - EC2_USER: ${{ secrets.EC2_USER }} - EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }} - EC2_SSH_PORT: ${{ secrets.EC2_SSH_PORT }} - EC2_APP_DIR: ${{ secrets.EC2_APP_DIR }} - PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }} - run: | - set -euo pipefail - missing=() - for name in EC2_HOST EC2_USER EC2_SSH_KEY EC2_SSH_PORT EC2_APP_DIR PRODUCTION_ENV; do - if [ -z "${!name}" ]; then - missing+=("$name") - fi - done - - for key in API_PORT CLIENT_URL CLIENT_URLS POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB JWT_SECRET; do - value="$(sed -n "s/^${key}=//p" <<< "$PRODUCTION_ENV" | tail -n 1)" - if [ -z "$value" ]; then - missing+=("PRODUCTION_ENV.${key}") - fi - done - - if grep -q "^DOCKER_IMAGE=" <<< "$PRODUCTION_ENV"; then - echo "PRODUCTION_ENV must not include DOCKER_IMAGE. The workflow injects it at deploy time." - exit 1 - fi - - if grep -q "^DATABASE_URL=" <<< "$PRODUCTION_ENV"; then - echo "PRODUCTION_ENV must not include DATABASE_URL. The workflow injects an encoded internal database URL at deploy time." - exit 1 - fi - - if [ "${#missing[@]}" -gt 0 ]; then - printf 'Missing required secrets: %s\n' "${missing[*]}" - exit 1 - fi - - - name: Create production env file - shell: bash - env: - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - EC2_HOST: ${{ secrets.EC2_HOST }} - IMAGE_TAG: ${{ needs.build-and-push.outputs.image_tag }} - PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }} - run: | - set -euo pipefail - mkdir -p .deploy - api_port="$(sed -n 's/^API_PORT=//p' <<< "$PRODUCTION_ENV" | tail -n 1 | tr -d '\r')" - 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')" - - if [[ ! "$api_port" =~ ^[0-9]+$ ]]; then - echo "PRODUCTION_ENV.API_PORT must be a numeric port." - exit 1 - fi - - 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;/^UPLOAD_PUBLIC_BASE_URL=/d' - printf 'DATABASE_URL=%s\n' "$database_url" - printf 'UPLOAD_PUBLIC_BASE_URL=http://%s:%s\n' "$EC2_HOST" "$api_port" - } > .deploy/production.env - - - name: Prepare EC2 app directory - uses: appleboy/ssh-action@v1.2.0 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} - port: ${{ secrets.EC2_SSH_PORT }} - script: | - set -eu - mkdir -p "${{ secrets.EC2_APP_DIR }}" - docker --version - docker compose version - - - name: Copy production compose file - uses: appleboy/scp-action@v0.1.7 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} - port: ${{ secrets.EC2_SSH_PORT }} - source: docker-compose.prod.yml - target: ${{ secrets.EC2_APP_DIR }} - overwrite: true - - - name: Copy production env file - uses: appleboy/scp-action@v0.1.7 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} - port: ${{ secrets.EC2_SSH_PORT }} - source: .deploy/production.env - target: ${{ secrets.EC2_APP_DIR }} - strip_components: 1 - overwrite: true - - - name: Deploy on EC2 - uses: appleboy/ssh-action@v1.2.0 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} - port: ${{ secrets.EC2_SSH_PORT }} - script: | - set -eu - cd "${{ secrets.EC2_APP_DIR }}" - mv production.env .env - - echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin - docker compose -f docker-compose.prod.yml pull api - docker compose -f docker-compose.prod.yml up -d - docker compose -f docker-compose.prod.yml ps - - api_port="$(sed -n 's/^API_PORT=//p' .env | tail -n 1 | tr -d '\r')" - for attempt in $(seq 1 18); do - if curl --fail --silent --show-error --max-time 10 "http://127.0.0.1:${api_port}/v1/health" > /tmp/soundlog-health.json; then - cat /tmp/soundlog-health.json - docker image prune -f - exit 0 - fi - - echo "Waiting for API health check on EC2 (${attempt}/18)..." - sleep 10 - done - - echo "API health check failed on EC2: http://127.0.0.1:${api_port}/v1/health" - docker compose -f docker-compose.prod.yml logs --tail=120 api - exit 1 - - - name: Sync frontend Vercel API origin - shell: bash - env: - EC2_HOST: ${{ secrets.EC2_HOST }} - FRONTEND_VERCEL_PROJECT: ${{ secrets.FRONTEND_VERCEL_PROJECT }} - FRONTEND_VERCEL_SCOPE: ${{ secrets.FRONTEND_VERCEL_SCOPE }} - FRONTEND_VERCEL_TOKEN: ${{ secrets.FRONTEND_VERCEL_TOKEN }} - PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }} - run: | - set -euo pipefail - - if [ -z "$FRONTEND_VERCEL_TOKEN" ]; then - echo "Skipping frontend Vercel API origin sync because FRONTEND_VERCEL_TOKEN is not configured." - exit 0 - fi - - api_port="$(sed -n 's/^API_PORT=//p' <<< "$PRODUCTION_ENV" | tail -n 1 | tr -d '\r')" - if [[ ! "$api_port" =~ ^[0-9]+$ ]]; then - echo "PRODUCTION_ENV.API_PORT must be a numeric port." - exit 1 - fi - - api_origin="http://${EC2_HOST}:${api_port}" - project="${FRONTEND_VERCEL_PROJECT:-sound-log-app}" - scope="${FRONTEND_VERCEL_SCOPE:-mannomis-projects}" - tmpdir="$(mktemp -d)" - - cleanup() { - rm -rf "$tmpdir" - } - trap cleanup EXIT - - npx --yes vercel@54.20.1 link \ - --yes \ - --scope "$scope" \ - --project "$project" \ - --cwd "$tmpdir" \ - --token "$FRONTEND_VERCEL_TOKEN" - - for environment in preview production; do - npx --yes vercel@54.20.1 env add SOUNDLOG_API_ORIGIN "$environment" \ - --force \ - --yes \ - --sensitive \ - --value "$api_origin" \ - --cwd "$tmpdir" \ - --scope "$scope" \ - --token "$FRONTEND_VERCEL_TOKEN" - done - - - name: Verify EC2 API contract - uses: appleboy/ssh-action@v1.2.0 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} - port: ${{ secrets.EC2_SSH_PORT }} - script: | - set -eu - cd "${{ secrets.EC2_APP_DIR }}" - 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 - - - name: Diagnose EC2 API network - uses: appleboy/ssh-action@v1.2.0 - with: - host: ${{ secrets.EC2_HOST }} - username: ${{ secrets.EC2_USER }} - key: ${{ secrets.EC2_SSH_KEY }} - port: ${{ secrets.EC2_SSH_PORT }} - script: | - set -eu - cd "${{ secrets.EC2_APP_DIR }}" - - api_port="$(sed -n 's/^API_PORT=//p' .env | tail -n 1 | tr -d '\r')" - echo "API_PORT=${api_port}" - - echo "::group::Docker compose state" - docker compose -f docker-compose.prod.yml ps - docker compose -f docker-compose.prod.yml port api 4000 || true - echo "::endgroup::" - - echo "::group::Listening sockets" - if command -v ss >/dev/null 2>&1; then - sudo -n ss -ltnp 2>/dev/null | grep -E "(:${api_port}|:4000|:80|:443)" || ss -ltnp | grep -E "(:${api_port}|:4000|:80|:443)" || true - else - netstat -ltnp 2>/dev/null | grep -E "(:${api_port}|:4000|:80|:443)" || true - fi - echo "::endgroup::" - - echo "::group::EC2 local health" - curl --fail --silent --show-error --max-time 5 "http://127.0.0.1:${api_port}/v1/health" || true - printf '\n' - metadata_token="$(curl --silent --show-error --max-time 5 -X PUT \ - -H 'X-aws-ec2-metadata-token-ttl-seconds: 60' \ - http://169.254.169.254/latest/api/token || true)" - metadata_curl() { - if [ -n "$metadata_token" ]; then - curl --silent --show-error --max-time 5 \ - -H "X-aws-ec2-metadata-token: ${metadata_token}" \ - "http://169.254.169.254/latest/meta-data/$1" || true - else - curl --silent --show-error --max-time 5 \ - "http://169.254.169.254/latest/meta-data/$1" || true - fi - } - public_ip="$(metadata_curl public-ipv4)" - echo "EC2 metadata public-ipv4=${public_ip:-unavailable}" - if [ -n "$public_ip" ]; then - curl --fail --silent --show-error --max-time 5 "http://${public_ip}:${api_port}/v1/health" || true - printf '\n' - fi - echo "::endgroup::" - - echo "::group::EC2 metadata network identity" - echo "instance-id=$(metadata_curl instance-id)" - echo "placement-region=$(metadata_curl placement/region)" - echo "placement-availability-zone=$(metadata_curl placement/availability-zone)" - macs="$(metadata_curl network/interfaces/macs/)" - printf 'network-interface-macs=%s\n' "${macs:-unavailable}" - first_mac="$(printf '%s\n' "$macs" | head -n 1)" - if [ -n "$first_mac" ]; then - metadata_mac_path="network/interfaces/macs/${first_mac}" - vpc_id="$(metadata_curl "${metadata_mac_path}vpc-id")" - subnet_id="$(metadata_curl "${metadata_mac_path}subnet-id")" - security_groups="$(metadata_curl "${metadata_mac_path}security-groups")" - security_group_ids="$(metadata_curl "${metadata_mac_path}security-group-ids")" - echo "vpc-id=${vpc_id}" - echo "subnet-id=${subnet_id}" - echo "security-groups=${security_groups}" - echo "security-group-ids=${security_group_ids}" - fi - echo "::endgroup::" - - echo "::group::Host firewall state" - if command -v firewall-cmd >/dev/null 2>&1; then - sudo -n firewall-cmd --state 2>/dev/null || true - sudo -n firewall-cmd --list-all 2>/dev/null || true - fi - if command -v ufw >/dev/null 2>&1; then - sudo -n ufw status verbose 2>/dev/null || true - fi - sudo -n iptables -S INPUT 2>/dev/null || true - echo "::endgroup::" - - - name: Open EC2 API ingress when AWS credentials are configured - shell: bash - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - AWS_SESSION_TOKEN: ${{ secrets.AWS_SESSION_TOKEN }} - AWS_REGION_SECRET: ${{ secrets.AWS_REGION }} - AWS_REGION_INPUT: ${{ inputs.aws_region }} - EC2_SECURITY_GROUP_ID_SECRET: ${{ secrets.EC2_SECURITY_GROUP_ID }} - EC2_SECURITY_GROUP_ID_INPUT: ${{ inputs.security_group_id }} - PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }} - run: | - set -euo pipefail - api_port="$(sed -n 's/^API_PORT=//p' <<< "$PRODUCTION_ENV" | tail -n 1 | tr -d '\r')" - aws_region="${AWS_REGION_INPUT:-${AWS_REGION_SECRET:-}}" - security_group_id="${EC2_SECURITY_GROUP_ID_INPUT:-${EC2_SECURITY_GROUP_ID_SECRET:-}}" - - if [[ ! "$api_port" =~ ^[0-9]+$ ]]; then - echo "PRODUCTION_ENV.API_PORT must be a numeric port." - exit 1 - fi - - missing=() - for name in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do - if [ -z "${!name}" ]; then - missing+=("$name") - fi - done - if [ -z "$aws_region" ]; then - missing+=("AWS_REGION") - fi - if [ -z "$security_group_id" ]; then - missing+=("EC2_SECURITY_GROUP_ID") - fi - - if [ "${#missing[@]}" -gt 0 ]; then - printf 'Skipping EC2 API ingress sync because optional values are missing: %s\n' "${missing[*]}" - exit 0 - fi - - if ! command -v aws >/dev/null 2>&1; then - python -m pip install --user awscli - export PATH="$HOME/.local/bin:$PATH" - fi - - ip_permissions="IpProtocol=tcp,FromPort=${api_port},ToPort=${api_port},IpRanges=[{CidrIp=0.0.0.0/0,Description=\"SoundLog API for Vercel rewrite\"}]" - set +e - output="$(aws ec2 authorize-security-group-ingress \ - --region "$aws_region" \ - --group-id "$security_group_id" \ - --ip-permissions "$ip_permissions" 2>&1)" - status=$? - set -e - - printf '%s\n' "$output" - if [ "$status" -eq 0 ]; then - echo "Opened TCP ${api_port} on ${security_group_id}." - exit 0 - fi - if grep -q "InvalidPermission.Duplicate" <<< "$output"; then - echo "TCP ${api_port} is already allowed on ${security_group_id}." - exit 0 - fi - - exit "$status" - - - name: Check EC2 API external reachability - shell: bash - env: - EC2_HOST: ${{ secrets.EC2_HOST }} - PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }} - run: | - set -euo pipefail - api_port="$(sed -n 's/^API_PORT=//p' <<< "$PRODUCTION_ENV" | tail -n 1 | tr -d '\r')" - - if [[ ! "$api_port" =~ ^[0-9]+$ ]]; then - echo "PRODUCTION_ENV.API_PORT must be a numeric port." - exit 1 - fi - - if ! curl --fail --silent --show-error --max-time 10 "http://${EC2_HOST}:${api_port}/v1/health" > /tmp/soundlog-external-health.json; then - echo "::error::EC2 API port is not reachable from the GitHub runner. Open TCP ${api_port} on the EC2 security group/firewall or point SOUNDLOG_API_ORIGIN to a reachable API origin before deploying the frontend." - exit 1 - fi - - cat /tmp/soundlog-external-health.json diff --git a/.github/workflows/deploy-gcp.yml b/.github/workflows/deploy-gcp.yml new file mode 100644 index 0000000..b77f720 --- /dev/null +++ b/.github/workflows/deploy-gcp.yml @@ -0,0 +1,261 @@ +name: Deploy API to GCP + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + image_tag: + description: Docker image tag to build and deploy. Defaults to the current commit SHA. + required: false + type: string + +concurrency: + group: deploy-api-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-push: + name: Build and push Docker image + 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_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: | + set -euo pipefail + missing=() + for name in DOCKERHUB_USERNAME DOCKERHUB_TOKEN; do + if [ -z "${!name}" ]; then + missing+=("$name") + fi + done + + if [ "${#missing[@]}" -gt 0 ]; then + printf 'Missing required secrets: %s\n' "${missing[*]}" + exit 1 + fi + + - name: Set image tag + id: meta + shell: bash + run: | + set -euo pipefail + image_tag="${GITHUB_SHA::12}" + + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -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 }} + ${{ secrets.DOCKERHUB_USERNAME }}/soundlog-server:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + name: Pull and restart on GCP + runs-on: ubuntu-latest + needs: build-and-push + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate deployment secrets + shell: bash + env: + GCP_HOST: ${{ secrets.GCP_HOST }} + GCP_USER: ${{ secrets.GCP_USER }} + GCP_SSH_KEY: ${{ secrets.GCP_SSH_KEY }} + GCP_SSH_PORT: ${{ secrets.GCP_SSH_PORT }} + GCP_APP_DIR: ${{ secrets.GCP_APP_DIR }} + PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }} + run: | + set -euo pipefail + missing=() + for name in GCP_HOST GCP_USER GCP_SSH_KEY GCP_SSH_PORT GCP_APP_DIR PRODUCTION_ENV; do + if [ -z "${!name}" ]; then + missing+=("$name") + fi + done + + for key in CLIENT_URLS POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB JWT_SECRET UPLOAD_PUBLIC_BASE_URL; do + value="$(sed -n "s/^${key}=//p" <<< "$PRODUCTION_ENV" | tail -n 1)" + if [ -z "$value" ]; then + missing+=("PRODUCTION_ENV.${key}") + fi + done + + if grep -q "^DOCKER_IMAGE=" <<< "$PRODUCTION_ENV"; then + echo "PRODUCTION_ENV must not include DOCKER_IMAGE. The workflow injects it at deploy time." + exit 1 + fi + + if grep -q "^DATABASE_URL=" <<< "$PRODUCTION_ENV"; then + echo "PRODUCTION_ENV must not include DATABASE_URL. The workflow injects an encoded internal database URL at deploy time." + exit 1 + fi + + if [ "${#missing[@]}" -gt 0 ]; then + printf 'Missing required secrets: %s\n' "${missing[*]}" + 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 }} + 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' + printf 'DATABASE_URL=%s\n' "$database_url" + } > .deploy/production.env + + - name: Prepare GCP app directory + 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 + mkdir -p "${{ secrets.GCP_APP_DIR }}" + sudo docker --version + sudo docker compose version + + - name: Copy production compose files + 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,Caddyfile + 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 on GCP + 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 + cd "${{ secrets.GCP_APP_DIR }}" + mv production.env .env + + 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 + sudo docker compose -f docker-compose.prod.yml ps + + for attempt in $(seq 1 18); 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((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"; then + sudo docker image prune -f + exit 0 + fi + + echo "Waiting for API health check on GCP (${attempt}/18)..." + sleep 10 + done + + echo "API health check failed on GCP." + sudo docker compose -f docker-compose.prod.yml logs --tail=120 api + exit 1 + + - name: Verify GCP API contract + 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 + cd "${{ secrets.GCP_APP_DIR }}" + 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 + + - name: Check public HTTPS reachability + shell: bash + run: | + set -eu + if curl --fail --silent --show-error --max-time 10 "https://api.soundlog.shop/v1/health" > /tmp/soundlog-external-health.json; then + cat /tmp/soundlog-external-health.json + for path in /legal/privacy /legal/terms /support; do + curl --fail --silent --show-error --head --max-time 10 "https://api.soundlog.shop${path}" > /dev/null + done + exit 0 + fi + + echo "::warning::https://api.soundlog.shop is not reachable yet. This is expected until the api.soundlog.shop DNS A record points at the GCP static IP and Caddy has issued a TLS certificate. The deploy itself succeeded; this check is informational only." diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..e076d73 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,3 @@ +api.soundlog.shop { + reverse_proxy api:4000 +} diff --git a/README.md b/README.md index 65aacbb..4ae2e2d 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=http://localhost:4000 npm run web ``` 웹 기본 주소는 `http://localhost:8081`입니다. -배포된 앱과 웹은 현재 Vercel의 `/api/soundlog` 프록시를 통해 EC2 API를 호출합니다. +Soundlog는 웹 서비스를 배포하지 않습니다. 배포된 iOS·Android 앱은 Vercel 프록시 없이 `https://api.soundlog.shop`을 직접 호출합니다. 자세한 내용은 [`docs/gcp-deployment.md`](docs/gcp-deployment.md)를 참고합니다. ## API Docs @@ -84,7 +84,7 @@ EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=http://localhost:4000 npm run web - Swagger UI: `http://localhost:4000/docs` - OpenAPI YAML: `http://localhost:4000/openapi.yaml` -- 운영 API 프록시: `https://soundlog.shop/api/soundlog` +- 운영 API: `https://api.soundlog.shop` Swagger에서 바로 DB 쓰기를 확인할 때는 인증 없이 호출 가능한 개발용 API를 사용할 수 있습니다. @@ -99,8 +99,8 @@ Swagger에서 바로 DB 쓰기를 확인할 때는 인증 없이 호출 가능 - `ALLOW_DEV_AUTH_FALLBACK=false` - 자체 이메일/비밀번호 로그인만 사용하며, 서버는 비밀번호 원문 대신 bcrypt hash만 저장 - `CLIENT_URLS`, `UPLOAD_PUBLIC_BASE_URL`, 앱의 `EXPO_PUBLIC_SOUNDLOG_API_BASE_URL`은 HTTPS 도메인 사용 -- 운영 기준 frontend origin은 `https://soundlog.shop`입니다. 공개 API URL은 `https://soundlog.shop/api/soundlog`이며, 별도 `api` 서브도메인은 사용하지 않습니다. -- `REQUEST_BODY_LIMIT`, `MOMENT_PHOTO_MAX_FILE_SIZE_MB`, `UPLOAD_DIRECTORY`, `UPLOAD_PUBLIC_PATH`는 운영 파일 업로드 정책에 맞게 조정 +- 운영 클라이언트는 네이티브 앱입니다. 공개 API URL은 `https://api.soundlog.shop`이며, GCP VM 위의 Caddy가 TLS를 직접 종료합니다. +- `REQUEST_BODY_LIMIT`, `MOMENT_PHOTO_MAX_FILE_SIZE_MB`, `UPLOAD_DIRECTORY`는 운영 파일 업로드 정책에 맞게 조정 - iOS 앱 설정에 전체 ATS 예외를 넣지 않기 서버 코드는 자체 계정 로그인(`POST /v1/auth/login`, `POST /v1/auth/register`)으로 Soundlog access/refresh token을 발급합니다. @@ -199,6 +199,6 @@ pnpm db:seed # 로컬 seed 데이터 적재 - `GET /v1/home/featured-playlists` - `GET /v1/home/mood-recommendations` - `GET /v1/home/recent-music-logs` - - `POST /v1/playlists/contextual` -> ML 추천 서버 `ML_RECOMMENDATION_API_URL` + - `POST /v1/playlists/contextual` -> HTTPS ML 추천 서버(설정된 경우), 아니면 seed fallback - `GET /v1/playlists/busan-ocean` - `GET /v1/recaps/seoul-night/share` diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 025a4fc..4584a6b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -28,7 +28,7 @@ services: DATABASE_URL: ${DATABASE_URL} JWT_SECRET: ${JWT_SECRET} JWT_EXPIRES_IN_SECONDS: ${JWT_EXPIRES_IN_SECONDS:-3600} - ML_RECOMMENDATION_API_URL: ${ML_RECOMMENDATION_API_URL:-http://211.188.54.204:8000/recommend} + ML_RECOMMENDATION_API_URL: ${ML_RECOMMENDATION_API_URL:-} ML_RECOMMENDATION_TIMEOUT_MS: ${ML_RECOMMENDATION_TIMEOUT_MS:-5000} REQUEST_BODY_LIMIT: ${REQUEST_BODY_LIMIT:-1mb} MOMENT_PHOTO_MAX_FILE_SIZE_MB: ${MOMENT_PHOTO_MAX_FILE_SIZE_MB:-10} @@ -41,8 +41,6 @@ services: UPLOAD_PUBLIC_BASE_URL: ${UPLOAD_PUBLIC_BASE_URL} UPLOAD_PUBLIC_PATH: ${UPLOAD_PUBLIC_PATH:-/uploads} USE_MOCK_DB: ${USE_MOCK_DB:-false} - ports: - - "${API_PORT:-4000}:4000" volumes: - uploads_data:/app/uploads healthcheck: @@ -56,6 +54,21 @@ services: retries: 3 start_period: 30s + caddy: + image: caddy:2-alpine + restart: unless-stopped + depends_on: + - api + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + volumes: postgres_data: uploads_data: + caddy_data: + caddy_config: diff --git a/docker-compose.yml b/docker-compose.yml index e95bc81..4fb6fce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,7 +32,7 @@ services: DATABASE_URL: postgresql://${POSTGRES_USER:-soundlog}:${POSTGRES_PASSWORD:-soundlog_password}@db:5432/${POSTGRES_DB:-soundlog}?schema=public JWT_SECRET: ${JWT_SECRET:-change-me-in-local-development} JWT_EXPIRES_IN_SECONDS: ${JWT_EXPIRES_IN_SECONDS:-3600} - ML_RECOMMENDATION_API_URL: ${ML_RECOMMENDATION_API_URL:-http://211.188.54.204:8000/recommend} + ML_RECOMMENDATION_API_URL: ${ML_RECOMMENDATION_API_URL:-} ML_RECOMMENDATION_TIMEOUT_MS: ${ML_RECOMMENDATION_TIMEOUT_MS:-5000} REQUEST_BODY_LIMIT: ${REQUEST_BODY_LIMIT:-1mb} MOMENT_PHOTO_MAX_FILE_SIZE_MB: ${MOMENT_PHOTO_MAX_FILE_SIZE_MB:-10} diff --git a/docs/ec2-deployment.md b/docs/ec2-deployment.md deleted file mode 100644 index f08bd80..0000000 --- a/docs/ec2-deployment.md +++ /dev/null @@ -1,123 +0,0 @@ -# EC2 deployment - -GitHub Actions builds the API image, pushes it to Docker Hub, then connects to EC2 and restarts the server with `docker compose`. - -## Required secrets - -Only workflow-level credentials stay as separate GitHub Secrets. Application environment values are grouped into one multiline `.env` secret. - -| Secret | Example | Description | -| --- | --- | --- | -| `DOCKERHUB_USERNAME` | `soundlogteam` | Docker Hub namespace. | -| `DOCKERHUB_TOKEN` | `dckr_pat_...` | Docker Hub access token with push/pull permission. | -| `EC2_HOST` | `54.226.62.131` | EC2 public IP or domain. | -| `EC2_USER` | `ec2-user` | SSH user. | -| `EC2_SSH_PORT` | `22` | SSH port. | -| `EC2_SSH_KEY` | PEM private key | Private key that can SSH into EC2. | -| `EC2_APP_DIR` | `/home/ec2-user/soundlog-server` | Directory where compose and `.env` are written. | -| `PRODUCTION_ENV` | multiline `.env` | Server runtime environment except generated deployment values. | -| `FRONTEND_VERCEL_TOKEN` | `vercel_...` | Optional. Enables automatic `SOUNDLOG_API_ORIGIN` sync for the frontend Vercel project. | -| `FRONTEND_VERCEL_SCOPE` | `mannomis-projects` | Optional. Vercel team/user scope for the frontend project. Defaults to `mannomis-projects`. | -| `FRONTEND_VERCEL_PROJECT` | `sound-log-app` | Optional. Frontend Vercel project name. Defaults to `sound-log-app`. | -| `AWS_ACCESS_KEY_ID` | `AKIA...` | Optional. Enables automatic EC2 API security-group ingress sync. | -| `AWS_SECRET_ACCESS_KEY` | `...` | Optional. Enables automatic EC2 API security-group ingress sync. | -| `AWS_SESSION_TOKEN` | `...` | Optional. Use only for temporary AWS credentials. | -| `AWS_REGION` | `us-east-1` | Optional. AWS region for the EC2 security group. Can also be provided as workflow dispatch input. | -| `EC2_SECURITY_GROUP_ID` | `sg-...` | Optional. Security group that should allow inbound `API_PORT`. Can also be provided as workflow dispatch input. | - -## `PRODUCTION_ENV` format - -Register `PRODUCTION_ENV` as a multiline secret with this format. - -```dotenv -API_PORT=4000 -CLIENT_URL=https://soundlog.shop -CLIENT_URLS=https://soundlog.shop,https://www.soundlog.shop,https://sound-log-app.vercel.app,http://localhost:8081,http://localhost:8082 -POSTGRES_USER=soundlog -POSTGRES_PASSWORD= -POSTGRES_DB=soundlog -JWT_SECRET= -JWT_EXPIRES_IN_SECONDS=3600 -ML_RECOMMENDATION_API_URL=http://211.188.54.204:8000/recommend -ML_RECOMMENDATION_TIMEOUT_MS=5000 -REQUEST_BODY_LIMIT=1mb -MOMENT_PHOTO_MAX_FILE_SIZE_MB=10 -REVERSE_GEOCODING_BASE_URL=https://nominatim.openstreetmap.org -REVERSE_GEOCODING_USER_AGENT=Soundlog/0.1 (+https://github.com/SoundLogTeam/SoundLogServer) -TOUR_API_BASE_URL=https://apis.data.go.kr/B551011/KorService2 -TOUR_API_SERVICE_KEY= -ALLOW_DEV_AUTH_FALLBACK=false -UPLOAD_PUBLIC_PATH=/uploads -USE_MOCK_DB=false -``` - -The workflow prepends `DOCKER_IMAGE=/soundlog-server:`, derives `UPLOAD_PUBLIC_BASE_URL=http://:`, and injects a URL-encoded internal `DATABASE_URL` at deploy time, so do not include those values in `PRODUCTION_ENV`. - -## `soundlog.shop` DNS and API proxy - -Use this DNS layout. - -| Host | Type | Target | -| --- | --- | --- | -| `soundlog.shop` | `A` | `76.76.21.21` | -| `www.soundlog.shop` | `CNAME` | `cname.vercel-dns.com.` | - -Do not use a separate API subdomain for the current deployment. The public API URL is: - -```txt -https://soundlog.shop/api/soundlog -``` - -The frontend Vercel project rewrites `/api/soundlog/:path*` to the EC2 API origin in the server-side Vercel layer. Set Vercel `SOUNDLOG_API_ORIGIN` to the current EC2 API origin, for example `http://:4000`. - -When `FRONTEND_VERCEL_TOKEN` is configured in this repo, the deploy workflow updates `SOUNDLOG_API_ORIGIN` for the frontend Vercel `preview` and `production` environments after each successful EC2 deploy. Re-run the frontend Vercel deployment after the env sync so the generated rewrite config is rebuilt. - -## Public API ingress - -The deploy workflow publishes the API container on `0.0.0.0:`, but Vercel can only proxy `/api/soundlog` when the EC2 security group also allows inbound TCP ``. If the deploy log fails at `Check EC2 API external reachability`, use the `security-group-ids=` value printed in `Diagnose EC2 API network` and open the API port. - -```sh -aws ec2 authorize-security-group-ingress \ - --region \ - --group-id \ - --ip-permissions 'IpProtocol=tcp,FromPort=4000,ToPort=4000,IpRanges=[{CidrIp=0.0.0.0/0,Description="SoundLog API for Vercel rewrite"}]' -``` - -The deploy log also prints `placement-region=` and `security-group-ids=` in `Diagnose EC2 API network`, so those values can be copied directly into the command. If `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, and `EC2_SECURITY_GROUP_ID` are configured as repository secrets, the deploy workflow tries this security-group sync automatically before the external reachability gate. If the rule already exists, AWS returns `InvalidPermission.Duplicate`; the workflow treats that as success and continues. - -## Register secrets with GitHub CLI - -Run these from any directory after `gh auth login`. - -```sh -gh secret set DOCKERHUB_USERNAME --repo SoundLogTeam/SoundLogServer --body '' -gh secret set DOCKERHUB_TOKEN --repo SoundLogTeam/SoundLogServer --body '' - -gh secret set EC2_HOST --repo SoundLogTeam/SoundLogServer --body '54.226.62.131' -gh secret set EC2_USER --repo SoundLogTeam/SoundLogServer --body 'ec2-user' -gh secret set EC2_SSH_PORT --repo SoundLogTeam/SoundLogServer --body '22' -gh secret set EC2_SSH_KEY --repo SoundLogTeam/SoundLogServer < ~/.ssh/soundlog-ec2.pem -gh secret set EC2_APP_DIR --repo SoundLogTeam/SoundLogServer --body '/home/ec2-user/soundlog-server' -gh secret set PRODUCTION_ENV --repo SoundLogTeam/SoundLogServer < .env.production - -gh secret set FRONTEND_VERCEL_TOKEN --repo SoundLogTeam/SoundLogServer --body '' -gh secret set FRONTEND_VERCEL_SCOPE --repo SoundLogTeam/SoundLogServer --body 'mannomis-projects' -gh secret set FRONTEND_VERCEL_PROJECT --repo SoundLogTeam/SoundLogServer --body 'sound-log-app' - -gh secret set AWS_ACCESS_KEY_ID --repo SoundLogTeam/SoundLogServer --body '' -gh secret set AWS_SECRET_ACCESS_KEY --repo SoundLogTeam/SoundLogServer --body '' -gh secret set AWS_REGION --repo SoundLogTeam/SoundLogServer --body '' -gh secret set EC2_SECURITY_GROUP_ID --repo SoundLogTeam/SoundLogServer --body '' -``` - -## Run deployment - -The workflow runs automatically after a push to a configured deployment branch. You can also deploy manually from GitHub Actions or CLI. - -```sh -gh workflow run deploy-ec2.yml --repo SoundLogTeam/SoundLogServer -``` - -EC2 must already have Docker Engine and Docker Compose v2 installed. The workflow keeps Postgres data in the `postgres_data` Docker volume and uploaded files in the `uploads_data` Docker volume. - -After deployment, the API container runs Prisma migrations and upserts the public music catalog used by the frontend, including seeded tracks, playlists, mood recommendations, and regional trends. It does not seed local/test tourist-place fixtures into production. The workflow then verifies `http://127.0.0.1:/v1/health` from inside EC2 and runs the API contract check from inside the deployed API container. It prints EC2 network diagnostics, including Docker port publishing, listening sockets, local health, public-IP self-curl, EC2 metadata network identity, placement region, security groups, and host firewall state. When optional AWS credentials are configured, it opens TCP `` on the configured security group. Finally, it verifies `http://:/v1/health` from GitHub Actions and fails the deploy if the public API port is unreachable. If this gate fails, open TCP `` on the reported EC2 security group/firewall or point `SOUNDLOG_API_ORIGIN` to a reachable API origin before rebuilding the frontend. After the frontend deployment is rebuilt with the synced Vercel env, verify `https://soundlog.shop/api/soundlog/v1/health` and run the app repo's deployed-web check. The app check logs in to protected APIs; set `SOUNDLOG_CHECK_EMAIL` and `SOUNDLOG_CHECK_PASSWORD` to reuse a smoke account, or omit them to let the script create a temporary `@soundlog.test` account. diff --git a/docs/gcp-deployment.md b/docs/gcp-deployment.md new file mode 100644 index 0000000..ac0b369 --- /dev/null +++ b/docs/gcp-deployment.md @@ -0,0 +1,123 @@ +# GCP deployment + +Soundlog는 웹 서비스를 배포하지 않습니다. GitHub Actions builds the API image, pushes it to Docker Hub, then connects to a GCP Compute Engine VM and restarts the server with `docker compose`. Postgres, the API and Caddy run as containers on the VM. The iOS·Android app calls `https://api.soundlog.shop` directly, and Caddy terminates HTTPS with an automatically issued Let's Encrypt certificate. + +## GCP resources + +| Resource | Value | +| --- | --- | +| Project | `nomi-app-deploy-2026` | +| Zone | `asia-northeast3-a` | +| VM | `soundlog-api` (`e2-medium`, Debian 12) | +| Static IP | reserved as `soundlog-api-ip` in `asia-northeast3` | +| Firewall | `soundlog-api-allow-ssh-http-https` allows tcp 22/80/443 to the `soundlog-api` tag | +| Cloud DNS zone | `soundlog-shop` for `soundlog.shop` | + +The VM's startup script installs Docker Engine + the Compose plugin and creates `/home/deploy/soundlog-server`. The deploy workflow uses SSH access to the VM's static IP and does not require a GCP service-account key. + +## Required secrets + +Only workflow-level credentials stay as separate GitHub Secrets. Application environment values are grouped into one multiline `.env` secret. + +| Secret | Example | Description | +| --- | --- | --- | +| `DOCKERHUB_USERNAME` | `soundlogteam` | Docker Hub namespace. | +| `DOCKERHUB_TOKEN` | `dckr_pat_...` | Docker Hub access token with push/pull permission. | +| `GCP_HOST` | `34.64.116.40` | GCP VM static external IP. | +| `GCP_USER` | `deploy` | SSH user provisioned on the VM. | +| `GCP_SSH_PORT` | `22` | SSH port. | +| `GCP_SSH_KEY` | private key | Private key for the dedicated `deploy` SSH keypair (not a personal key). | +| `GCP_APP_DIR` | `/home/deploy/soundlog-server` | Directory where compose files and `.env` are written. | +| `PRODUCTION_ENV` | multiline `.env` | Server runtime environment except generated deployment values. | + +## `PRODUCTION_ENV` format + +Register `PRODUCTION_ENV` as a multiline secret with this format. + +```dotenv +CLIENT_URL=https://api.soundlog.shop +CLIENT_URLS=https://api.soundlog.shop,http://localhost:8081,http://localhost:8082 +POSTGRES_USER=soundlog +POSTGRES_PASSWORD= +POSTGRES_DB=soundlog +JWT_SECRET= +JWT_EXPIRES_IN_SECONDS=3600 +# Optional. Configure only an HTTPS endpoint. Empty or non-HTTPS values use seed fallback. +ML_RECOMMENDATION_API_URL= +ML_RECOMMENDATION_TIMEOUT_MS=5000 +REQUEST_BODY_LIMIT=1mb +MOMENT_PHOTO_MAX_FILE_SIZE_MB=10 +REVERSE_GEOCODING_BASE_URL=https://nominatim.openstreetmap.org +REVERSE_GEOCODING_USER_AGENT=Soundlog/0.1 (+https://github.com/SoundLogTeam/SoundLogServer) +TOUR_API_BASE_URL=https://apis.data.go.kr/B551011/KorService2 +TOUR_API_SERVICE_KEY= +ALLOW_DEV_AUTH_FALLBACK=false +UPLOAD_PUBLIC_BASE_URL=https://api.soundlog.shop +USE_MOCK_DB=false +``` + +`UPLOAD_PUBLIC_BASE_URL` is a fixed HTTPS value because Caddy terminates TLS at the same domain. The workflow prepends `DOCKER_IMAGE=/soundlog-server:` and injects a URL-encoded internal `DATABASE_URL` at deploy time, so do not include those values in `PRODUCTION_ENV`. + +`ML_RECOMMENDATION_API_URL` is optional. In production, the server ignores a non-HTTPS value and returns the existing seed recommendation fallback without sending location or mood to the ML server. Run `pnpm check:production-env` before deployment to surface that configuration. + +`TOUR_API_SERVICE_KEY` ships blank; set the real data.go.kr key if the tour-recommendation feature needs to work in production. + +## `api.soundlog.shop` DNS + +GCP project `nomi-app-deploy-2026` has a public Cloud DNS zone named `soundlog-shop`. Delegate the domain at the registrar to these nameservers: + +- `ns-cloud-d1.googledomains.com` +- `ns-cloud-d2.googledomains.com` +- `ns-cloud-d3.googledomains.com` +- `ns-cloud-d4.googledomains.com` + +The zone contains this record: + +| Type | Host | Value | +| --- | --- | --- | +| `A` | `api` | `34.64.116.40` | + +Caddy cannot issue a Let's Encrypt certificate for `api.soundlog.shop` until the registrar delegates the domain to Cloud DNS. Existing AWS Route 53 and Vercel nameservers are not part of the production path. + +## Register secrets with GitHub CLI + +Run these from any directory after `gh auth login`. + +```sh +gh secret set DOCKERHUB_USERNAME --repo SoundLogTeam/SoundLogServer --body '' +gh secret set DOCKERHUB_TOKEN --repo SoundLogTeam/SoundLogServer --body '' + +gh secret set GCP_HOST --repo SoundLogTeam/SoundLogServer --body '34.64.116.40' +gh secret set GCP_USER --repo SoundLogTeam/SoundLogServer --body 'deploy' +gh secret set GCP_SSH_PORT --repo SoundLogTeam/SoundLogServer --body '22' +gh secret set GCP_SSH_KEY --repo SoundLogTeam/SoundLogServer < ~/.ssh/soundlog-gcp-deploy +gh secret set GCP_APP_DIR --repo SoundLogTeam/SoundLogServer --body '/home/deploy/soundlog-server' +gh secret set PRODUCTION_ENV --repo SoundLogTeam/SoundLogServer < .env.production + +``` + +## Run deployment + +The workflow runs automatically after a push to `main`. You can also deploy manually from GitHub Actions or CLI. + +```sh +gh workflow run deploy-gcp.yml --repo SoundLogTeam/SoundLogServer +``` + +After deployment, the API container runs Prisma migrations and upserts the public music catalog used by the app. The workflow verifies API health from inside the `api` container over SSH and runs the API contract check from inside the deployed container. It then checks `https://api.soundlog.shop/v1/health` from GitHub Actions; this step is informational until DNS delegation is complete. + +## Verification + +```sh +dig +short api.soundlog.shop A +curl https://api.soundlog.shop/v1/health +curl -I https://api.soundlog.shop/legal/privacy +curl -I https://api.soundlog.shop/legal/terms +curl -I https://api.soundlog.shop/support +``` + +Run from the SoundLogApp repo to validate the full contract against the live origin: + +```sh +SOUNDLOG_API_ORIGIN=https://api.soundlog.shop npm run check:api-origin +``` diff --git a/package.json b/package.json index a4158b8..e26cbd3 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", + "express-rate-limit": "^8.6.1", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fb45b3..a5fce33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: express: specifier: ^5.1.0 version: 5.2.1 + express-rate-limit: + specifier: ^8.6.1 + version: 8.6.1(express@5.2.1) helmet: specifier: ^8.1.0 version: 8.2.0 @@ -371,36 +374,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.3': resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.3': resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.3': resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.3': resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.3': resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.3': resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} @@ -780,6 +789,12 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.6.1: + resolution: {integrity: sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -881,6 +896,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ip-address@10.4.0: + resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -952,24 +971,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -2048,6 +2071,14 @@ snapshots: expect-type@1.3.0: {} + express-rate-limit@8.6.1(express@5.2.1): + dependencies: + debug: 4.4.3 + express: 5.2.1 + ip-address: 10.4.0 + transitivePeerDependencies: + - supports-color + express@5.2.1: dependencies: accepts: 2.0.0 @@ -2186,6 +2217,8 @@ snapshots: inherits@2.0.4: {} + ip-address@10.4.0: {} + ipaddr.js@1.9.1: {} is-promise@4.0.0: {} diff --git a/prisma/seed.ts b/prisma/seed.ts index dfd4b38..15bf1c3 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -881,6 +881,7 @@ export async function seedPublicCatalog() { await seedPlaylists(); await seedMoodRecommendations(); await seedRegionSoundTrends(); + await seedPlaces(); } export async function seedDatabase() { @@ -950,7 +951,6 @@ export async function seedDatabase() { }); await seedPublicCatalog(); - await seedPlaces(); const seedRoutePoints = recaps[0]?.routePoints ?? []; diff --git a/scripts/check-live-e2e.mjs b/scripts/check-live-e2e.mjs index c87ab19..721501e 100644 --- a/scripts/check-live-e2e.mjs +++ b/scripts/check-live-e2e.mjs @@ -126,7 +126,7 @@ let primary; let companion; try { - await step('system health, OpenAPI, docs, and DB write', async () => { + await step('system health, OpenAPI, and docs', async () => { const health = await request('/v1/health'); assert(health.payload?.data?.status === 'ok', 'Health status is not ok.'); assert(health.payload?.data?.database === 'ok', 'Database status is not ok.'); @@ -135,12 +135,10 @@ try { assert(String(openApi.payload).includes('openapi: 3.1.0'), 'OpenAPI document is missing.'); await request('/docs/', { expectedStatus: 200 }); - const dbRecord = await request('/v1/dev/db-test-records', { - body: { label: `live-e2e-${runId}`, payload: { source: 'check-live-e2e' } }, - expectedStatus: 201, - method: 'POST', - }); - assert(dbRecord.payload?.data?.id, 'DB test write did not return an id.'); + // /v1/dev/db-test-records now requires auth and is unregistered in + // production, so it is no longer exercised by this unauthenticated + // smoke step. DB write behavior is still covered by the authenticated + // steps below (registration, recap captures, etc.). }); await step('register, login, refresh, profile, and migration', async () => { diff --git a/scripts/check-production-env.mjs b/scripts/check-production-env.mjs index 1c84d19..6bd5310 100644 --- a/scripts/check-production-env.mjs +++ b/scripts/check-production-env.mjs @@ -40,10 +40,23 @@ if (process.env.ALLOW_DEV_AUTH_FALLBACK === 'true') { addError('ALLOW_DEV_AUTH_FALLBACK must be false or unset in production.'); } +if (process.env.AUTH_RATE_LIMIT_ENABLED === 'false') { + addError('AUTH_RATE_LIMIT_ENABLED must not be false in production.'); +} + if (!isHttpsUrl(process.env.UPLOAD_PUBLIC_BASE_URL)) { addError('UPLOAD_PUBLIC_BASE_URL must be an HTTPS URL.'); } +if ( + process.env.ML_RECOMMENDATION_API_URL && + !isHttpsUrl(process.env.ML_RECOMMENDATION_API_URL) +) { + addWarning( + 'ML_RECOMMENDATION_API_URL is not HTTPS and will be disabled; seed recommendations will be used.', + ); +} + const clientUrls = (process.env.CLIENT_URLS ?? process.env.CLIENT_URL ?? '') .split(',') .map((url) => url.trim()) diff --git a/src/app.ts b/src/app.ts index 7285583..90476b4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,22 +10,29 @@ import { errorMiddleware } from './middlewares/error.middleware.js'; import { requestLoggerMiddleware } from './middlewares/request-logger.middleware.js'; import { securityMiddleware } from './middlewares/security.middleware.js'; import { registerSwaggerDocs } from './middlewares/swagger.middleware.js'; -import { - uploadedFilesPublicPath, - uploadedFilesStaticMiddleware, -} from './middlewares/upload.middleware.js'; import { createApiRouter } from './routes/index.js'; +import { createLegalRouter } from './routes/legal.router.js'; +import { createUploadsRouter } from './routes/uploads.router.js'; import { notFound } from './utils/http-error.js'; export function createApp() { const app = express(); + // Behind a single Caddy reverse proxy hop (see Caddyfile / docker-compose.prod.yml). + // Trusting exactly 1 hop lets req.ip reflect the real client IP (needed for + // rate limiting) without allowing X-Forwarded-For spoofing from the client. + app.set('trust proxy', 1); + app.use(corsMiddleware); app.use(securityMiddleware); app.use(jsonBodyParserMiddleware); app.use(urlencodedBodyParserMiddleware); app.use(requestLoggerMiddleware); - app.use(uploadedFilesPublicPath, uploadedFilesStaticMiddleware); + app.use(createLegalRouter()); + // Uploaded photos are served only through an authenticated, ownership/visibility-checked + // endpoint (see uploads.router.ts) — there is no unauthenticated static file serving of + // the uploads directory. + app.use(createUploadsRouter()); registerSwaggerDocs(app); app.use(createApiRouter()); app.use((_req, _res, next) => { diff --git a/src/config/env.ts b/src/config/env.ts index 8026293..62e4d6d 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -7,15 +7,20 @@ const envSchema = z.object({ .string() .optional() .transform((value) => value === 'true'), + AUTH_RATE_LIMIT_ENABLED: z.string().optional(), + AUTH_RATE_LIMIT_IP_MAX: z.coerce.number().int().positive().default(40), + AUTH_RATE_LIMIT_IP_WINDOW_MS: z.coerce.number().int().positive().default(15 * 60 * 1000), + AUTH_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(10), + AUTH_RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(15 * 60 * 1000), CLIENT_URL: z.string().url().default('http://localhost:8081'), CLIENT_URLS: z.string().optional(), DATABASE_URL: z.string().min(1), JWT_EXPIRES_IN_SECONDS: z.coerce.number().int().positive().default(3600), JWT_SECRET: z.string().min(16), - ML_RECOMMENDATION_API_URL: z - .string() - .url() - .default('http://211.188.54.204:8000/recommend'), + ML_RECOMMENDATION_API_URL: z.preprocess( + (value) => (value === '' ? undefined : value), + z.string().url().optional(), + ), ML_RECOMMENDATION_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), MOMENT_PHOTO_MAX_FILE_SIZE_MB: z.coerce.number().int().positive().default(10), NODE_ENV: z.string().default('development'), @@ -37,7 +42,27 @@ const envSchema = z.object({ .transform((value) => value === 'true'), UPLOAD_DIRECTORY: z.string().min(1).default('uploads'), UPLOAD_PUBLIC_BASE_URL: z.string().url().default('http://localhost:4000'), - UPLOAD_PUBLIC_PATH: z.string().min(1).default('/uploads'), }); -export const env = envSchema.parse(process.env); +const parsedEnv = envSchema.parse(process.env); + +const mlRecommendationApiUrl = + parsedEnv.NODE_ENV === 'production' && + parsedEnv.ML_RECOMMENDATION_API_URL && + new URL(parsedEnv.ML_RECOMMENDATION_API_URL).protocol !== 'https:' + ? undefined + : parsedEnv.ML_RECOMMENDATION_API_URL; + +export const env = { + ...parsedEnv, + // Production recommendation requests can include precise location and mood. + // Drop a legacy plaintext endpoint so callers use their local fallback instead. + ML_RECOMMENDATION_API_URL: mlRecommendationApiUrl, + // Defaults to disabled under NODE_ENV=test so existing tests that call + // auth endpoints repeatedly are not destabilized. Set + // AUTH_RATE_LIMIT_ENABLED=true explicitly to exercise the limiter in tests. + AUTH_RATE_LIMIT_ENABLED: + parsedEnv.AUTH_RATE_LIMIT_ENABLED === undefined + ? parsedEnv.NODE_ENV !== 'test' + : parsedEnv.AUTH_RATE_LIMIT_ENABLED === 'true', +}; diff --git a/src/controllers/upload-file.controller.ts b/src/controllers/upload-file.controller.ts new file mode 100644 index 0000000..b4abf5b --- /dev/null +++ b/src/controllers/upload-file.controller.ts @@ -0,0 +1,37 @@ +import type { Request, Response } from 'express'; + +import { requireUser } from '../middlewares/auth.middleware.js'; +import { uploadFileService } from '../services/upload-file.service.js'; +import { notFound } from '../utils/http-error.js'; + +export const uploadFileController = { + async getUploadedFile(req: Request, res: Response) { + const user = requireUser(req); + const fileId = String(req.params.fileId); + + const resolved = await uploadFileService.resolveUploadedFileForUser(user.id, fileId); + + // Unknown file, disallowed access, and invalid/traversal file ids all resolve the + // same way (undefined) and all produce the same 404, so a caller cannot use the + // response to tell a private file that doesn't belong to them apart from a file + // that simply doesn't exist. + if (!resolved) { + throw notFound(); + } + + // multer stores uploads with no extension, so Content-Type must be derived from the + // file's actual bytes (never the client-supplied upload MIME type or a filename), or + // helmet's `X-Content-Type-Options: nosniff` leaves browsers refusing to render it. If + // the bytes don't match a known image signature, the file is not served as an image at + // all — this also covers legacy/unexpected on-disk files that happen to have a matching + // DB row but aren't actually images. + const contentType = await uploadFileService.detectImageContentType(resolved.absolutePath); + + if (!contentType) { + throw notFound(); + } + + res.type(contentType); + res.sendFile(resolved.absolutePath); + }, +}; diff --git a/src/middlewares/rate-limit.middleware.ts b/src/middlewares/rate-limit.middleware.ts new file mode 100644 index 0000000..4142950 --- /dev/null +++ b/src/middlewares/rate-limit.middleware.ts @@ -0,0 +1,77 @@ +import type { Request, Response } from 'express'; +import { ipKeyGenerator, rateLimit } from 'express-rate-limit'; + +import { env } from '../config/env.js'; + +function accountKeyGenerator(req: Request) { + const email = + typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''; + const ipKey = ipKeyGenerator(req.ip ?? 'unknown'); + + // Endpoints without an email in the body (e.g. refresh) fall back to an + // IP-only key here; the IP-only limiter below still applies independently. + return email ? `${ipKey}:${email}` : ipKey; +} + +function ipKeyGeneratorForRequest(req: Request) { + return ipKeyGenerator(req.ip ?? 'unknown'); +} + +function handler(_req: Request, res: Response) { + res.status(429).json({ + error: { + code: 'TOO_MANY_REQUESTS', + message: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.', + details: {}, + }, + }); +} + +const skip = () => !env.AUTH_RATE_LIMIT_ENABLED; + +/** + * Applies only to the auth endpoints that are most attractive to credential + * stuffing / brute-force / account-enumeration attacks + * (login, register, refresh). Not applied API-wide. + * + * Per-account limiter (keyed by IP + email): stops repeated attempts against + * a single known account. On its own this does NOT stop credential + * stuffing, where an attacker tries many different emails from the same IP + * — each email is a fresh bucket. See authIpRateLimitMiddleware below for + * that case; both are chained on the routes. + * + * Disabled by default under NODE_ENV=test (see src/config/env.ts) so it does + * not destabilize existing test suites that call these endpoints repeatedly; + * can be forced on via AUTH_RATE_LIMIT_ENABLED=true for dedicated tests. + */ +export const authAccountRateLimitMiddleware = rateLimit({ + handler, + keyGenerator: accountKeyGenerator, + legacyHeaders: false, + limit: env.AUTH_RATE_LIMIT_MAX, + skip, + standardHeaders: true, + windowMs: env.AUTH_RATE_LIMIT_WINDOW_MS, +}); + +/** + * Per-IP limiter (keyed by IP only, ignores email/account). This is the + * actual defense against credential stuffing, where an attacker cycles + * through many different email addresses from one IP — the account-scoped + * limiter above would treat each attempt as a fresh bucket, but this one + * catches the aggregate volume from that IP regardless of which account is + * targeted. Deliberately looser than the account limit so legitimate users + * behind shared/NAT IPs are unlikely to be affected. + * + * Shares the same enable/disable and response behavior as the account + * limiter above. + */ +export const authIpRateLimitMiddleware = rateLimit({ + handler, + keyGenerator: ipKeyGeneratorForRequest, + legacyHeaders: false, + limit: env.AUTH_RATE_LIMIT_IP_MAX, + skip, + standardHeaders: true, + windowMs: env.AUTH_RATE_LIMIT_IP_WINDOW_MS, +}); diff --git a/src/middlewares/upload.middleware.ts b/src/middlewares/upload.middleware.ts index 7536193..f617669 100644 --- a/src/middlewares/upload.middleware.ts +++ b/src/middlewares/upload.middleware.ts @@ -1,4 +1,3 @@ -import express from 'express'; import multer from 'multer'; import { env } from '../config/env.js'; @@ -7,28 +6,50 @@ const BYTES_PER_MEGABYTE = 1024 * 1024; const MOMENT_PHOTO_MAX_FILE_SIZE_BYTES = env.MOMENT_PHOTO_MAX_FILE_SIZE_MB * BYTES_PER_MEGABYTE; -function normalizePublicPath(value: string) { - const trimmed = value.trim(); - const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; - - return withLeadingSlash.replace(/\/+$/, '') || '/'; -} - -export const uploadedFilesPublicPath = normalizePublicPath(env.UPLOAD_PUBLIC_PATH); - -export const uploadedFilesStaticMiddleware = express.static(env.UPLOAD_DIRECTORY); +// Client-declared MIME types accepted at upload time. This is a cheap, spoofable +// first line of defense (multer's fileFilter only sees the multipart part's declared +// Content-Type, not the actual bytes) that simply stops obviously-wrong uploads (PDFs, +// executables, etc.) from ever being written to disk. The real security boundary is the +// magic-byte sniff performed when serving the file back out (see +// upload-file.service.ts#detectImageContentType), which never trusts this value. +const ALLOWED_MOMENT_PHOTO_MIME_TYPES = new Set([ + 'image/gif', + 'image/heic', + 'image/heif', + 'image/jpeg', + 'image/png', + 'image/webp', +]); + +// multer's default disk storage names files with crypto.randomBytes(16).toString('hex'), +// i.e. exactly 32 lowercase hex characters and nothing else (no path separators, no dots). +// This pattern is the single source of truth for what a valid stored filename looks like, +// and is used both to generate public-facing file ids and to validate/reject any client +// supplied file id (blocking path traversal, absolute paths, encoded separators, etc.) +// before it is ever used to build a filesystem path. +export const UPLOAD_FILE_ID_PATTERN = /^[a-f0-9]{32}$/; + +export const UPLOADED_FILE_ROUTE_PATH = '/v1/uploads'; export const momentPhotoUpload = multer({ dest: env.UPLOAD_DIRECTORY, + fileFilter: (_req, file, callback) => { + if (!ALLOWED_MOMENT_PHOTO_MIME_TYPES.has(file.mimetype)) { + // Using multer's own error type (rather than an arbitrary Error) means the existing + // errorMiddleware `multer.MulterError` branch turns this into a 400 automatically — + // no changes needed there. Rejecting via this callback happens before multer's + // storage engine writes anything for this part, so no file is left on disk. + callback(new multer.MulterError('LIMIT_UNEXPECTED_FILE', file.fieldname)); + return; + } + + callback(null, true); + }, limits: { fileSize: MOMENT_PHOTO_MAX_FILE_SIZE_BYTES, }, }); export function createUploadedFilePublicPath(filename: string) { - if (uploadedFilesPublicPath === '/') { - return `/${filename}`; - } - - return `${uploadedFilesPublicPath}/${filename}`; + return `${UPLOADED_FILE_ROUTE_PATH}/${filename}`; } diff --git a/src/routes/index.ts b/src/routes/index.ts index ce99e3a..e6aa9ae 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -16,8 +16,13 @@ import { travelSessionController, trendController, } from '../controllers/index.js'; +import { env } from '../config/env.js'; import { asyncHandler } from '../utils/async-handler.js'; import { authMiddleware } from '../middlewares/auth.middleware.js'; +import { + authAccountRateLimitMiddleware, + authIpRateLimitMiddleware, +} from '../middlewares/rate-limit.middleware.js'; import { momentPhotoUpload } from '../middlewares/upload.middleware.js'; import { validate } from '../middlewares/validate.middleware.js'; import { @@ -40,24 +45,37 @@ export function createApiRouter() { const router = Router(); router.get('/v1/health', asyncHandler(systemController.getHealth)); - router.post( - '/v1/dev/db-test-records', - validate({ body: devDbTestValidators.createBody }), - asyncHandler(devDbTestController.createRecord), - ); + + // Dev-only DB smoke-test endpoint. Never registered in production, and + // requires auth everywhere else (see src/services/dev-db-test.service.ts + // for a defensive production guard as well). + if (env.NODE_ENV !== 'production') { + router.post( + '/v1/dev/db-test-records', + authMiddleware, + validate({ body: devDbTestValidators.createBody }), + asyncHandler(devDbTestController.createRecord), + ); + } router.post( '/v1/auth/login', + authIpRateLimitMiddleware, + authAccountRateLimitMiddleware, validate({ body: authValidators.loginBody }), asyncHandler(authController.login), ); router.post( '/v1/auth/register', + authIpRateLimitMiddleware, + authAccountRateLimitMiddleware, validate({ body: authValidators.registerBody }), asyncHandler(authController.register), ); router.post( '/v1/auth/refresh', + authIpRateLimitMiddleware, + authAccountRateLimitMiddleware, validate({ body: authValidators.refreshBody }), asyncHandler(authController.refresh), ); diff --git a/src/routes/legal.router.ts b/src/routes/legal.router.ts new file mode 100644 index 0000000..4a41565 --- /dev/null +++ b/src/routes/legal.router.ts @@ -0,0 +1,186 @@ +import { Router } from 'express'; + +const SUPPORT_EMAIL = 'support@soundlog.shop'; +const EFFECTIVE_DATE = '2026.08.02'; + +type LegalSection = { + body: string; + title: string; +}; + +type LegalPage = { + description: string; + sections: LegalSection[]; + title: string; +}; + +const privacyPage: LegalPage = { + title: '개인정보 처리방침', + description: + 'Soundlog iOS 및 Android 앱의 정보 처리, 삭제 방법과 앱 지원용 공개 고지를 안내합니다.', + sections: [ + { + title: '적용 범위', + body: '이 페이지는 Soundlog 모바일 앱의 개인정보 처리와 지원을 위한 공개 법적 고지입니다. Soundlog는 별도의 웹 서비스나 웹 계정 기능을 제공하지 않으며, 이 페이지는 앱 설치 전후에 정책과 지원 방법을 확인할 수 있도록 제공됩니다.', + }, + { + title: '수집하는 정보', + body: 'Soundlog는 이메일 계정 가입과 로그인을 위해 계정 이름, 이메일 주소, 사용자 ID와 비밀번호 인증 정보를 처리합니다. 앱 기능을 위해 사용자가 선택하거나 입력한 음악 취향, 여행 스타일, 좋아요와 저장한 음악, 장소와 시간, 음악 정보, 메모·댓글 등 사용자 콘텐츠를 처리할 수 있습니다. 리캡 기록에는 사용자가 촬영하거나 선택한 사진과 리캡 이미지가 포함될 수 있으며, 현재 앱은 영상 업로드 기능을 제공하지 않습니다.', + }, + { + title: '계정 연결과 추적', + body: '계정 이름, 이메일 주소, 사용자 ID, 위치, 사진, 사용자 콘텐츠와 제품 상호작용 정보는 계정의 기록·동기화·개인화 기능을 위해 Soundlog 계정과 연결하여 처리할 수 있습니다. Soundlog는 이 정보를 다른 회사의 앱 또는 웹사이트 데이터와 결합해 맞춤 광고를 제공하거나 광고 효과를 측정하는 방식으로 추적하지 않습니다.', + }, + { + title: '위치와 사진 권한', + body: '위치 권한은 현재 장소에 맞는 추천, 여행 순간의 장소 기록과 여행 경로 기록에 사용합니다. 이 과정에서 정확한 위도·경도와 기록 시각을 처리할 수 있습니다. 위치는 앱 사용 중에만 요청하며 백그라운드 위치 추적은 사용하지 않습니다. 카메라와 사진 보관함 권한은 사용자가 직접 촬영하거나 선택한 사진을 리캡에 저장하고, 사용자가 요청한 리캡 이미지를 사진 보관함에 저장하는 데 사용합니다.', + }, + { + title: '제품 상호작용 분석과 이용 목적', + body: 'Soundlog는 곡 선택·좋아요·저장, 플레이리스트 열기, 무드 변경, 리캡 저장·공유·이미지 저장과 같은 제품 상호작용 정보를 분석할 수 있습니다. 이 정보는 음악 추천과 기록 기능 제공, 계정 동기화, 서비스 사용성 및 안정성 개선을 위해 사용하며, 제3자 광고 목적의 판매에는 사용하지 않습니다.', + }, + { + title: '제3자 서비스', + body: '사용자가 현재 장소 확인, 역지오코딩 또는 주변 관광지 추천 기능을 이용하면 정확한 좌표가 Nominatim(OpenStreetMap)과 한국관광공사 공공데이터 API에 전달될 수 있습니다. HTTPS로 설정된 추천 서버를 사용하는 경우에는 음악 추천을 위해 좌표와 무드·여행 상태가 해당 서버에 전달될 수 있습니다. 각 제공자의 정책이 적용될 수 있습니다.', + }, + { + title: '외부 음악 링크', + body: 'Soundlog는 외부 음악 서비스의 검색 또는 재생 링크를 열 수 있습니다. 링크를 열면 해당 서비스의 정책이 적용되며, Soundlog는 외부 음악 서비스 계정 정보나 재생 계정을 수집하지 않습니다.', + }, + { + title: '보관과 삭제', + body: '계정 데이터와 여행 기록은 계정 기반 앱 기능을 제공하는 동안 처리합니다. 앱의 My 화면에서 계정 삭제를 실행하면 계정, 인증 토큰, 여행 기록, 리캡, 보관함, 커뮤니티 데이터와 서버에 저장된 리캡 사진을 삭제하며 복구할 수 없습니다. 운영 로그 또는 백업의 보관 기간은 이 페이지에서 구체적으로 정하지 않으며, 해당 정보의 처리 기준이 필요한 경우 고객지원으로 문의할 수 있습니다.', + }, + { + title: '문의', + body: `개인정보와 데이터 삭제 문의는 ${SUPPORT_EMAIL}으로 보낼 수 있습니다.`, + }, + ], +}; + +const termsPage: LegalPage = { + title: '서비스 이용약관', + description: 'Soundlog 모바일 앱을 사용할 때 적용되는 기본 조건을 안내합니다.', + sections: [ + { + title: '서비스 이용', + body: 'Soundlog는 위치와 여행 맥락을 바탕으로 음악 추천, 순간 기록, 리캡과 여행 로그 생성을 제공하는 모바일 앱 서비스입니다. 이 공개 페이지는 앱 지원과 법적 고지용이며 별도의 웹 서비스를 제공하지 않습니다. 사용자는 본인의 기기와 계정에서 발생하는 활동에 대한 책임이 있습니다.', + }, + { + title: '계정 기반 이용', + body: 'Soundlog의 추천, 좋아요, 여행 기록과 Recap 기능은 로그인된 Soundlog 계정에서 사용할 수 있습니다. 온보딩과 약관 확인은 로그인 전에도 볼 수 있지만 주요 기능 이용에는 계정 로그인이 필요합니다.', + }, + { + title: '사용자 콘텐츠', + body: '사용자가 촬영하거나 저장한 사진, 장소, 음악 메모와 Recap 자료의 권리는 사용자에게 있습니다. Soundlog는 서비스 제공, 동기화와 공유 기능 제공에 필요한 범위에서만 이를 처리합니다.', + }, + { + title: '외부 서비스', + body: '장소 정보 등 공공 관광 데이터 제공자의 서비스가 사용될 수 있으며 해당 제공자의 정책이 함께 적용될 수 있습니다. Soundlog는 외부 음원 재생을 보장하지 않습니다.', + }, + { + title: '제한 사항', + body: '타인의 권리를 침해하는 콘텐츠, 불법적인 목적의 이용, 서비스 안정성을 해치는 행위는 허용되지 않습니다. 필요한 경우 서비스 이용이 제한될 수 있습니다.', + }, + { + title: '문의와 변경', + body: `약관 또는 서비스 이용 문의는 ${SUPPORT_EMAIL}으로 보낼 수 있습니다. 약관이 변경되는 경우 앱 또는 스토어 고지를 통해 안내합니다.`, + }, + ], +}; + +function renderPage(page: LegalPage) { + const sections = page.sections + .map( + ({ body, title }) => ` +
+

${title}

+

${body}

+
`, + ) + .join(''); + + return ` + + + + + + ${page.title} | Soundlog + + + +
+
+

Soundlog

+

${page.title}

+

${page.description}

+

시행일 ${EFFECTIVE_DATE}

+
+ ${sections} + +
+ +`; +} + +function renderSupportPage() { + return renderPage({ + title: '고객지원', + description: + 'Soundlog 모바일 앱 이용과 데이터 처리에 관한 문의 방법을 안내하는 공개 지원 페이지입니다.', + sections: [ + { + title: '앱 지원 페이지', + body: '이 페이지는 Soundlog iOS 및 Android 앱의 고객지원과 법적 고지를 위한 공개 페이지입니다. 별도의 웹 서비스나 웹 계정 기능을 제공하지 않습니다.', + }, + { + title: '이메일 문의', + body: `앱 이용, 계정, 개인정보 처리, 데이터 삭제와 오류 문의는 ${SUPPORT_EMAIL}으로 보내주세요.`, + }, + { + title: '계정 및 데이터 삭제', + body: '앱의 My 화면에서 계정 삭제를 실행할 수 있습니다. 계정 삭제는 계정과 연관된 기록·리캡·보관함·커뮤니티 데이터 및 서버에 저장된 리캡 사진을 삭제합니다. 앱에 접근할 수 없는 경우 가입 이메일과 함께 고객지원 메일로 요청해 주세요.', + }, + ], + }); +} + +export function createLegalRouter() { + const router = Router(); + + router.get('/legal/privacy', (_request, response) => { + response.set('Cache-Control', 'public, max-age=300'); + response.type('html').send(renderPage(privacyPage)); + }); + router.get('/legal/terms', (_request, response) => { + response.set('Cache-Control', 'public, max-age=300'); + response.type('html').send(renderPage(termsPage)); + }); + router.get('/support', (_request, response) => { + response.set('Cache-Control', 'public, max-age=300'); + response.type('html').send(renderSupportPage()); + }); + + return router; +} diff --git a/src/routes/uploads.router.ts b/src/routes/uploads.router.ts new file mode 100644 index 0000000..f7540fd --- /dev/null +++ b/src/routes/uploads.router.ts @@ -0,0 +1,18 @@ +import { Router } from 'express'; + +import { uploadFileController } from '../controllers/upload-file.controller.js'; +import { authMiddleware } from '../middlewares/auth.middleware.js'; +import { UPLOADED_FILE_ROUTE_PATH } from '../middlewares/upload.middleware.js'; +import { asyncHandler } from '../utils/async-handler.js'; + +export function createUploadsRouter() { + const router = Router(); + + router.get( + `${UPLOADED_FILE_ROUTE_PATH}/:fileId`, + authMiddleware, + asyncHandler(uploadFileController.getUploadedFile), + ); + + return router; +} diff --git a/src/services/dev-db-test.service.ts b/src/services/dev-db-test.service.ts index 194113a..21e8e4a 100644 --- a/src/services/dev-db-test.service.ts +++ b/src/services/dev-db-test.service.ts @@ -40,6 +40,13 @@ function toDto(record: { export const devDbTestService = { async createRecord(input: CreateDbTestRecordInput) { + // Defense in depth: this endpoint must never write to the database in + // production, even if it were ever reachable there (e.g. misconfigured + // routing). The route itself is also not registered in production. + if (env.NODE_ENV === 'production') { + throw new Error('devDbTestService.createRecord is disabled in production.'); + } + const label = input.label ?? 'swagger-db-test'; const payload = input.payload ?? {}; diff --git a/src/services/soundlog.service.ts b/src/services/soundlog.service.ts index b67b83e..50bfc7c 100644 --- a/src/services/soundlog.service.ts +++ b/src/services/soundlog.service.ts @@ -25,6 +25,7 @@ import path from 'node:path'; import { env } from '../config/env.js'; import { ERROR_MESSAGES } from '../constants/error.constants.js'; import { prisma } from '../config/prisma.js'; +import { UPLOAD_FILE_ID_PATTERN } from '../middlewares/upload.middleware.js'; import { getLimit, paginateByCursor } from '../utils/pagination.js'; import { createPublicId } from '../utils/tokens.js'; import { badRequest, forbidden, notFound } from '../utils/http-error.js'; @@ -609,12 +610,21 @@ function getLocalUploadedFilePath(photoUrl?: string | null) { return undefined; } - const uploadPublicRoot = normalizePublicUrl(env.UPLOAD_PUBLIC_BASE_URL, env.UPLOAD_PUBLIC_PATH); - const fileName = photoUrl.startsWith(`${uploadPublicRoot}/`) - ? photoUrl.slice(uploadPublicRoot.length + 1) - : undefined; + // Only the trailing path segment matters: it is validated against the same fileId + // pattern the uploads endpoint enforces, so this works for both the current + // `/v1/uploads/` URLs and any legacy `/uploads/` URLs already stored + // in the database, without trusting anything else in the string. + let pathname: string; - if (!fileName || fileName.includes('/') || fileName.includes('\\')) { + try { + pathname = new URL(photoUrl).pathname; + } catch { + pathname = photoUrl; + } + + const fileName = pathname.split('/').pop(); + + if (!fileName || !UPLOAD_FILE_ID_PATTERN.test(fileName)) { return undefined; } @@ -628,7 +638,9 @@ async function deleteLocalUploadedFile(photoUrl?: string | null) { return; } - await fs.unlink(filePath).catch(() => undefined); + await fs.unlink(filePath).catch((error) => { + console.warn(`Failed to delete uploaded file at ${filePath}`, error); + }); } function asString(value: unknown) { @@ -878,7 +890,7 @@ async function fetchMlRecommendationPlaylist( ): Promise { const location = input.location; - if (!location) { + if (!location || !env.ML_RECOMMENDATION_API_URL) { return undefined; } @@ -2205,15 +2217,24 @@ export const soundlogService = { }, idempotencyKey?: string, ) { - return withIdempotency( - { idempotencyKey, scope: 'moment-log.create', userId }, - async () => { - const location = - input.lat !== undefined && input.lng !== undefined - ? { lat: input.lat, lng: input.lng } - : undefined; + // multer already wrote the uploaded file to disk before this runs. If the request + // turns out to be an idempotent duplicate (withIdempotency returns a cached response + // without invoking the action below) or if the action throws before a MomentLog row + // is committed, the just-written file is never referenced by any row and would be + // orphaned on disk. `persisted` tracks whether this call actually attached the file + // to a saved row so the `finally` block can clean it up in every other case. + let persisted = false; - assertPublicRecapHasLocation(input.visibility, location); + try { + return await withIdempotency( + { idempotencyKey, scope: 'moment-log.create', userId }, + async () => { + const location = + input.lat !== undefined && input.lng !== undefined + ? { lat: input.lat, lng: input.lng } + : undefined; + + assertPublicRecapHasLocation(input.visibility, location); const track = input.trackId ? await prisma.track.findUnique({ where: { id: input.trackId } }) @@ -2264,9 +2285,22 @@ export const soundlogService = { return created; }); + persisted = true; + return momentLogToDto(log); - }, - ); + }, + ); + } finally { + // Cached idempotent replay (action above never ran) or a thrown error before the + // row was committed both leave `persisted` false — in either case the file multer + // just wrote is orphaned and should not linger on disk. Deletion failures are only + // logged (see deleteLocalUploadedFile) and never override the real response/error. + if (!persisted && input.photoPath) { + await deleteLocalUploadedFile( + normalizePublicUrl(env.UPLOAD_PUBLIC_BASE_URL, input.photoPath), + ); + } + } }, async updateMomentLog( @@ -2389,36 +2423,52 @@ export const soundlogService = { async updateMomentLogPhoto(userId: string, momentLogId: string, photoPath: string) { const nextPhotoUrl = normalizePublicUrl(env.UPLOAD_PUBLIC_BASE_URL, photoPath); - const existing = await prisma.momentLog.findFirst({ - where: { - id: momentLogId, - userId, - }, - }); + // multer already wrote the new photo to disk before this runs. `persisted` tracks + // whether it actually got attached to a saved row (target not found, or the DB + // transaction throwing, both leave it false) so the `finally` block below can clean up + // the newly uploaded file in every case that isn't a successful replace — mirroring the + // same orphan-file guard used in createMomentLog. + let persisted = false; - if (!existing) { - await deleteLocalUploadedFile(nextPhotoUrl); - throw notFound(ERROR_MESSAGES.MOMENT_LOG_NOT_FOUND); - } - - const updated = await prisma.$transaction(async (transaction) => { - const nextMoment = await transaction.momentLog.update({ - where: { id: existing.id }, - data: { photoUrl: nextPhotoUrl }, + try { + const existing = await prisma.momentLog.findFirst({ + where: { + id: momentLogId, + userId, + }, }); - await refreshRecapAggregates(transaction, { - momentIds: [existing.id], - sessionIds: [existing.sessionId], - userId, + if (!existing) { + throw notFound(ERROR_MESSAGES.MOMENT_LOG_NOT_FOUND); + } + + const updated = await prisma.$transaction(async (transaction) => { + const nextMoment = await transaction.momentLog.update({ + where: { id: existing.id }, + data: { photoUrl: nextPhotoUrl }, + }); + + await refreshRecapAggregates(transaction, { + momentIds: [existing.id], + sessionIds: [existing.sessionId], + userId, + }); + + return nextMoment; }); - return nextMoment; - }); + persisted = true; - await deleteLocalUploadedFile(existing.photoUrl); + // Only delete the previous photo after the new one is safely committed, so a crash + // or failure between these two steps never leaves the moment without any photo file. + await deleteLocalUploadedFile(existing.photoUrl); - return momentLogToDto(updated); + return momentLogToDto(updated); + } finally { + if (!persisted) { + await deleteLocalUploadedFile(nextPhotoUrl); + } + } }, async deleteMomentLogPhoto(userId: string, momentLogId: string) { diff --git a/src/services/upload-file.service.ts b/src/services/upload-file.service.ts new file mode 100644 index 0000000..3d4aeb5 --- /dev/null +++ b/src/services/upload-file.service.ts @@ -0,0 +1,161 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { env } from '../config/env.js'; +import { prisma } from '../config/prisma.js'; +import { UPLOAD_FILE_ID_PATTERN } from '../middlewares/upload.middleware.js'; + +const uploadRoot = path.resolve(env.UPLOAD_DIRECTORY); + +export type ResolvedUploadedFile = { + absolutePath: string; +}; + +// multer's disk storage writes uploaded files under a random 32-hex-character name with no +// extension, so `res.sendFile()` has nothing to infer a Content-Type from and Express falls +// back to `application/octet-stream`. Combined with helmet's `X-Content-Type-Options: nosniff` +// (which tells browsers not to sniff the body themselves), that means images would never +// render on the web. The fix is to determine the real image type ourselves from the file's +// leading bytes — never from the client-supplied upload MIME type or a filename extension, +// both of which are trivially spoofable — and set Content-Type explicitly before serving. +const IMAGE_HEADER_SNIFF_BYTES = 12; + +type ImageMagicByteSignature = { + contentType: string; + matches: (header: Buffer) => boolean; +}; + +const IMAGE_MAGIC_BYTE_SIGNATURES: ImageMagicByteSignature[] = [ + { + contentType: 'image/jpeg', + matches: (header) => + header.length >= 3 && header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff, + }, + { + contentType: 'image/png', + matches: (header) => + header.length >= 8 && + header[0] === 0x89 && + header[1] === 0x50 && + header[2] === 0x4e && + header[3] === 0x47 && + header[4] === 0x0d && + header[5] === 0x0a && + header[6] === 0x1a && + header[7] === 0x0a, + }, + { + contentType: 'image/gif', + matches: (header) => + header.length >= 6 && + header.subarray(0, 3).toString('ascii') === 'GIF' && + ['87a', '89a'].includes(header.subarray(3, 6).toString('ascii')), + }, + { + contentType: 'image/webp', + matches: (header) => + header.length >= 12 && + header.subarray(0, 4).toString('ascii') === 'RIFF' && + header.subarray(8, 12).toString('ascii') === 'WEBP', + }, + { + // HEIC/HEIF files are ISO base media (MP4-family) containers: bytes 4-8 are the literal + // string "ftyp" and bytes 8-12 are a 4-character "brand" identifying the specific format. + contentType: 'image/heic', + matches: (header) => { + if (header.length < 12 || header.subarray(4, 8).toString('ascii') !== 'ftyp') { + return false; + } + + const brand = header.subarray(8, 12).toString('ascii'); + + return ['heic', 'heim', 'heis', 'heix', 'hevc', 'hevx', 'mif1', 'msf1'].includes(brand); + }, + }, +]; + +/** + * Sniffs the first bytes of a file already resolved via `resolveUploadedFileForUser` and + * returns the matching image Content-Type, or `undefined` if the bytes don't match any + * known image signature. Callers must not serve the file as an image (and should not fall + * back to a client-supplied or extension-derived type) when this returns `undefined`. + */ +async function detectImageContentType(absolutePath: string): Promise { + const fileHandle = await fs.open(absolutePath, 'r'); + + try { + const header = Buffer.alloc(IMAGE_HEADER_SNIFF_BYTES); + const { bytesRead } = await fileHandle.read(header, 0, IMAGE_HEADER_SNIFF_BYTES, 0); + const signatureHeader = header.subarray(0, bytesRead); + + return IMAGE_MAGIC_BYTE_SIGNATURES.find((signature) => signature.matches(signatureHeader)) + ?.contentType; + } finally { + await fileHandle.close(); + } +} + +/** + * Resolves a client-supplied `fileId` (the last path segment of a stored photoUrl) to an + * absolute file path on disk, but only if the requesting user is allowed to see it. + * + * Returns `undefined` for every failure case (invalid id, unknown file, missing file on + * disk, or an access check that fails) so callers can respond with an indistinguishable + * 404 regardless of whether the file exists — this avoids leaking the existence of + * private resources. + */ +async function resolveUploadedFileForUser( + userId: string, + fileId: string, +): Promise { + // Reject anything that is not exactly a 32-character hex string up front. This blocks + // path traversal (`../`), absolute paths, URL-encoded separators (`%2e%2e%2f`), null + // bytes, and any other shape before it ever touches the filesystem or the database. + if (!UPLOAD_FILE_ID_PATTERN.test(fileId)) { + return undefined; + } + + // The uploaded file's owning MomentLog is looked up by matching the stored photoUrl's + // trailing `/` segment. The DB — not the client — is the source of truth for + // which filename exists and who owns it. + const momentLog = await prisma.momentLog.findFirst({ + where: { photoUrl: { endsWith: `/${fileId}` } }, + select: { userId: true, visibility: true }, + }); + + if (!momentLog) { + return undefined; + } + + const isOwner = momentLog.userId === userId; + const isPublic = momentLog.visibility === 'public'; + + if (!isOwner && !isPublic) { + return undefined; + } + + // Build the path from the server-known upload root and the validated fileId only, then + // re-verify (defense in depth) that the resolved path is still inside the upload root. + const absolutePath = path.resolve(uploadRoot, fileId); + + if (absolutePath !== path.join(uploadRoot, fileId) || !absolutePath.startsWith(`${uploadRoot}${path.sep}`)) { + return undefined; + } + + try { + const stat = await fs.stat(absolutePath); + + if (!stat.isFile()) { + return undefined; + } + } catch { + return undefined; + } + + return { absolutePath }; +} + +export const uploadFileService = { + detectImageContentType, + resolveUploadedFileForUser, +}; diff --git a/tests/api.test.ts b/tests/api.test.ts index 166ae72..d439697 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -1,13 +1,31 @@ import bcrypt from 'bcrypt'; +import fs from 'node:fs/promises'; +import path from 'node:path'; import request from 'supertest'; import { beforeAll, describe, expect, it, vi } from 'vitest'; import { createApp } from '../src/app.js'; +import { env } from '../src/config/env.js'; import { prisma } from '../src/config/prisma.js'; import { mockDb, resetMockDb } from '../src/mock/mock-db.js'; import { reverseGeocodeLocation } from '../src/services/reverse-geocoding.service.js'; import { disconnectSeedDatabase, seedDatabase } from '../prisma/seed.js'; +function fileIdFromPhotoUrl(photoUrl: string) { + return new URL(photoUrl).pathname.split('/').pop() as string; +} + +// Real JPEG SOI + APP0/JFIF magic bytes. The server now determines Content-Type for +// GET /v1/uploads/:fileId purely from the file's leading bytes (see +// upload-file.service.ts#detectImageContentType) rather than trusting the client-supplied +// upload MIME type, so test fixtures need genuine image bytes for "serves the file back +// out" assertions to mean anything. +const JPEG_MAGIC_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]); + +function fakeJpegBuffer(label: string) { + return Buffer.concat([JPEG_MAGIC_BYTES, Buffer.from(label)]); +} + const app = createApp(); const useMockDb = process.env.USE_MOCK_DB === 'true'; @@ -116,7 +134,7 @@ async function createTestMomentLog(input: { } const response = await requestBuilder - .attach('photo', Buffer.from('fake-image'), { + .attach('photo', fakeJpegBuffer('fake-image'), { filename: input.filename, contentType: 'image/jpeg', }); @@ -173,7 +191,7 @@ describe('Soundlog API', () => { expect(v1Docs.headers.location).toBe('/docs'); }); - it('creates a DB test record without auth', async () => { + it('rejects the dev DB test route without auth', async () => { const response = await request(app) .post('/v1/dev/db-test-records') .send({ @@ -183,6 +201,21 @@ describe('Soundlog API', () => { }, }); + expect(response.status).toBe(401); + expect(response.body.error.code).toBe('UNAUTHORIZED'); + }); + + it('creates a DB test record with auth', async () => { + const response = await request(app) + .post('/v1/dev/db-test-records') + .set('Authorization', authHeader) + .send({ + label: 'swagger-smoke-test', + payload: { + source: 'api-test', + }, + }); + expect(response.status).toBe(201); expect(response.body.data.id).toEqual(expect.any(String)); expect(response.body.data.label).toBe('swagger-smoke-test'); @@ -675,7 +708,7 @@ describe('Soundlog API', () => { .field('note', '카페 거리에서 남긴 테스트 메모') .field('placeName', '테스트 장소') .field('trackId', 'seoul-city') - .attach('photo', Buffer.from('fake-image'), { + .attach('photo', fakeJpegBuffer('fake-image'), { filename: 'moment.jpg', contentType: 'image/jpeg', }); @@ -693,7 +726,7 @@ describe('Soundlog API', () => { .field('note', '중복 요청 메모는 반영되지 않아야 함') .field('placeName', '중복 요청 장소') .field('trackId', 'seoul-city') - .attach('photo', Buffer.from('fake-image'), { + .attach('photo', fakeJpegBuffer('fake-image'), { filename: 'moment-duplicate.jpg', contentType: 'image/jpeg', }); @@ -735,7 +768,7 @@ describe('Soundlog API', () => { .field('note', '새 리캡 캡처 경로 테스트') .field('placeName', '리캡 캡처 테스트 장소') .field('trackId', 'seoul-city') - .attach('photo', Buffer.from('alias-image'), { + .attach('photo', fakeJpegBuffer('alias-image'), { filename: 'recap-capture.jpg', contentType: 'image/jpeg', }); @@ -783,7 +816,7 @@ describe('Soundlog API', () => { const photoUpdated = await request(app) .put(`/v1/moment-logs/${created.body.data.id}/photo`) .set('Authorization', authHeader) - .attach('photo', Buffer.from('replacement-image'), { + .attach('photo', fakeJpegBuffer('replacement-image'), { filename: 'moment-replacement.jpg', contentType: 'image/jpeg', }); @@ -806,7 +839,7 @@ describe('Soundlog API', () => { const aliasPhotoUpdated = await request(app) .put(`/v1/recap-captures/${aliasCreated.body.data.id}/photo`) .set('Authorization', authHeader) - .attach('photo', Buffer.from('alias-replacement-image'), { + .attach('photo', fakeJpegBuffer('alias-replacement-image'), { filename: 'recap-capture-replacement.jpg', contentType: 'image/jpeg', }); @@ -1259,17 +1292,22 @@ describe('Soundlog API', () => { trackTitle: '한강에서', }); expect(JSON.stringify(mixedVisibilityMarker)).not.toContain('비공개 장소 이름'); + expect( + publicMarkersAfterUpdate.body.data.some( + (marker: { recapId: string }) => marker.recapId === farCreated.body.data.id, + ), + ).toBe(false); - const fixedRadiusMarkers = await request(app) + const expandedRadiusMarkers = await request(app) .get('/v1/recap-markers') .query({ lat: 37.5512, lng: 126.9882, radiusMeters: 5000, scope: 'public' }) .set('Authorization', authHeader); - expect(fixedRadiusMarkers.status).toBe(200); + expect(expandedRadiusMarkers.status).toBe(200); expect( - fixedRadiusMarkers.body.data.some( + expandedRadiusMarkers.body.data.some( (marker: { recapId: string }) => marker.recapId === farCreated.body.data.id, ), - ).toBe(false); + ).toBe(true); const duplicate = await request(app) .post('/v1/recaps') @@ -1860,6 +1898,375 @@ describe('Soundlog API', () => { expect(response.body.data.topTracks.length).toBeGreaterThan(0); }); + (useMockDb ? describe.skip : describe)('GET /v1/uploads/:fileId access control', () => { + it('lets the owner fetch their own private moment photo', async () => { + const moment = await createTestMomentLog({ + authHeader, + filename: 'owner-private.jpg', + placeName: '업로드 접근 테스트', + }); + const fileId = fileIdFromPhotoUrl(moment.photoUrl); + + const response = await request(app) + .get(`/v1/uploads/${fileId}`) + .set('Authorization', authHeader); + + expect(response.status).toBe(200); + }); + + it('returns 404 (not 403) when another user requests a private photo', async () => { + const moment = await createTestMomentLog({ + authHeader, + filename: 'owner-private-other.jpg', + placeName: '업로드 접근 테스트', + }); + const fileId = fileIdFromPhotoUrl(moment.photoUrl); + const otherAccessToken = await getToken(); + + const response = await request(app) + .get(`/v1/uploads/${fileId}`) + .set('Authorization', `Bearer ${otherAccessToken}`); + + expect(response.status).toBe(404); + }); + + it('rejects an unauthenticated request for a private photo', async () => { + const moment = await createTestMomentLog({ + authHeader, + filename: 'owner-private-anon.jpg', + placeName: '업로드 접근 테스트', + }); + const fileId = fileIdFromPhotoUrl(moment.photoUrl); + + const response = await request(app).get(`/v1/uploads/${fileId}`); + + expect([401, 404]).toContain(response.status); + }); + + it('lets another authenticated user fetch a public moment photo', async () => { + const moment = await createTestMomentLog({ + authHeader, + filename: 'owner-public.jpg', + lat: 37.5665, + lng: 126.978, + placeName: '업로드 접근 테스트(공개)', + visibility: 'public', + }); + const fileId = fileIdFromPhotoUrl(moment.photoUrl); + const otherAccessToken = await getToken(); + + const response = await request(app) + .get(`/v1/uploads/${fileId}`) + .set('Authorization', `Bearer ${otherAccessToken}`); + + expect(response.status).toBe(200); + }); + + it('returns 404 for a well-formed file id that does not exist', async () => { + const response = await request(app) + .get(`/v1/uploads/${'a'.repeat(32)}`) + .set('Authorization', authHeader); + + expect(response.status).toBe(404); + }); + + it('blocks path traversal, absolute paths, and encoded separators in the file id', async () => { + const maliciousIds = [ + '../../../etc/passwd', + '..%2f..%2f..%2fetc%2fpasswd', + '%2e%2e%2f%2e%2e%2fsrc%2fapp.ts', + encodeURIComponent('../../../etc/passwd'), + encodeURIComponent('/etc/passwd'), + // Double URL-encoding: decodes once (by Express) to a still-encoded traversal + // sequence, which must still fail the anchored hex pattern rather than being + // decoded a second time and slipping through. + '%252e%252e%252fetc%252fpasswd', + encodeURIComponent(encodeURIComponent('../../../etc/passwd')), + // Backslash variants (meaningful as a path separator on Windows filesystems). + '..\\..\\..\\etc\\passwd', + encodeURIComponent('..\\..\\..\\etc\\passwd'), + '%5c..%5c..%5cetc%5cpasswd', + // A well-formed 32-hex id with an extra path segment before or after it. + `${'a'.repeat(32)}/../../../etc/passwd`, + `some-prefix/${'a'.repeat(32)}`, + `${'a'.repeat(32)}/extra-suffix`, + encodeURIComponent('a'.repeat(32) + ''), + ]; + + for (const maliciousId of maliciousIds) { + const response = await request(app) + .get(`/v1/uploads/${maliciousId}`) + .set('Authorization', authHeader); + + expect(response.status).not.toBe(200); + expect(response.text ?? '').not.toContain('root:'); + } + }); + + it('rejects traversal attempts hidden behind a query string', async () => { + const response = await request(app) + .get('/v1/uploads/..%2f..%2f..%2fetc%2fpasswd?x=1') + .set('Authorization', authHeader); + + expect(response.status).not.toBe(200); + expect(response.text ?? '').not.toContain('root:'); + }); + + it('still resolves a valid file id when a harmless query string is appended', async () => { + const moment = await createTestMomentLog({ + authHeader, + filename: 'owner-with-query.jpg', + placeName: '쿼리스트링 테스트', + }); + const fileId = fileIdFromPhotoUrl(moment.photoUrl); + + const response = await request(app) + .get(`/v1/uploads/${fileId}?cachebust=1`) + .set('Authorization', authHeader); + + expect(response.status).toBe(200); + }); + + (useMockDb ? it.skip : it)( + 'returns 404 for a real on-disk file that has no matching DB record', + async () => { + const uploadDir = path.resolve(env.UPLOAD_DIRECTORY); + const orphanFileId = 'f'.repeat(32); + const orphanFilePath = path.join(uploadDir, orphanFileId); + + await fs.writeFile(orphanFilePath, fakeJpegBuffer('untracked-file')); + + try { + const response = await request(app) + .get(`/v1/uploads/${orphanFileId}`) + .set('Authorization', authHeader); + + expect(response.status).toBe(404); + } finally { + await fs.unlink(orphanFilePath).catch(() => undefined); + } + }, + ); + + it('sets an image Content-Type derived from the file bytes and keeps nosniff enabled', async () => { + const moment = await createTestMomentLog({ + authHeader, + filename: 'owner-content-type.jpg', + placeName: '콘텐츠 타입 테스트', + }); + const fileId = fileIdFromPhotoUrl(moment.photoUrl); + + const response = await request(app) + .get(`/v1/uploads/${fileId}`) + .set('Authorization', authHeader); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toMatch(/^image\/jpeg/); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + }); + + (useMockDb ? it.skip : it)( + 'never serves a stored file as an image when its on-disk bytes are not a recognized image signature', + async () => { + const uploadDir = path.resolve(env.UPLOAD_DIRECTORY); + + const moment = await createTestMomentLog({ + authHeader, + filename: 'will-be-corrupted.jpg', + placeName: '비이미지 바이트 테스트', + }); + const fileId = fileIdFromPhotoUrl(moment.photoUrl); + const filePath = path.join(uploadDir, fileId); + + // Overwrites the on-disk bytes with non-image content while keeping the same + // filename/DB row, so the fileId is legitimately owned but the bytes are not an + // image. This confirms the GET endpoint's own magic-byte check — not the + // upload-time filter — is the real boundary for what gets served as an image. + await fs.writeFile(filePath, Buffer.from('not an image at all')); + + const response = await request(app) + .get(`/v1/uploads/${fileId}`) + .set('Authorization', authHeader); + + expect(response.status).toBe(404); + }, + ); + }); + + describe('upload MIME whitelist', () => { + it('rejects a disallowed declared MIME type without writing a file to disk', async () => { + const uploadDir = path.resolve(env.UPLOAD_DIRECTORY); + const filesBefore = useMockDb ? undefined : new Set(await fs.readdir(uploadDir)); + + const response = await request(app) + .post('/v1/moment-logs') + .set('Authorization', authHeader) + .field('createdAt', new Date().toISOString()) + .field('moodTags', 'fresh') + .field('placeName', 'MIME 화이트리스트 테스트') + .attach('photo', Buffer.from('#!/bin/sh\necho not an image\n'), { + contentType: 'application/x-sh', + filename: 'not-an-image.sh', + }); + + expect(response.status).toBe(400); + expect(response.body.error.code).toBe('BAD_REQUEST'); + + if (!useMockDb) { + const filesAfter = new Set(await fs.readdir(uploadDir)); + expect(filesAfter.size).toBe(filesBefore!.size); + } + }); + + it('accepts every image type on the allowed MIME whitelist', async () => { + const allowedMimeTypes = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/heic', + 'image/heif', + 'image/gif', + ]; + + for (const mimeType of allowedMimeTypes) { + const response = await request(app) + .post('/v1/moment-logs') + .set('Authorization', authHeader) + .field('createdAt', new Date().toISOString()) + .field('moodTags', 'fresh') + .field('placeName', `MIME 허용 테스트 ${mimeType}`) + .attach('photo', fakeJpegBuffer(mimeType), { + contentType: mimeType, + filename: `allowed.${mimeType.split('/')[1]}`, + }); + + expect(response.status).toBe(201); + } + }); + }); + + (useMockDb ? describe.skip : describe)('orphaned upload cleanup on failure', () => { + it('deletes the newly uploaded file when creating the MomentLog row fails', async () => { + const uploadDir = path.resolve(env.UPLOAD_DIRECTORY); + const filesBefore = new Set(await fs.readdir(uploadDir)); + + // createMomentLog writes the row inside `prisma.$transaction(async (transaction) => + // ...)`. The `transaction` client Prisma hands to that callback is a distinct proxy + // per call, so spying on `prisma.momentLog.create` would never intercept it — + // `$transaction` itself is the right interception point to simulate a failure deep + // inside the write. + const transactionSpy = vi + .spyOn(prisma, '$transaction') + .mockRejectedValueOnce(new Error('simulated DB failure')); + + try { + const response = await request(app) + .post('/v1/moment-logs') + .set('Authorization', authHeader) + .field('createdAt', new Date().toISOString()) + .field('moodTags', 'fresh') + .field('placeName', 'DB 실패 정리 테스트') + .attach('photo', fakeJpegBuffer('db-failure'), { + contentType: 'image/jpeg', + filename: 'db-failure.jpg', + }); + + expect(response.status).toBe(500); + } finally { + transactionSpy.mockRestore(); + } + + const filesAfter = new Set(await fs.readdir(uploadDir)); + expect(filesAfter.size).toBe(filesBefore.size); + }); + + it('deletes the newly uploaded replacement photo (keeping the original) when the photo-update transaction fails', async () => { + const moment = await createTestMomentLog({ + authHeader, + filename: 'photo-update-db-failure.jpg', + placeName: 'DB 실패 시 교체 사진 정리 테스트', + }); + const originalFileId = fileIdFromPhotoUrl(moment.photoUrl); + + const uploadDir = path.resolve(env.UPLOAD_DIRECTORY); + const filesBefore = new Set(await fs.readdir(uploadDir)); + + const transactionSpy = vi + .spyOn(prisma, '$transaction') + .mockRejectedValueOnce(new Error('simulated DB failure')); + + try { + const response = await request(app) + .put(`/v1/moment-logs/${moment.id}/photo`) + .set('Authorization', authHeader) + .attach('photo', fakeJpegBuffer('replacement-db-failure'), { + contentType: 'image/jpeg', + filename: 'replacement-db-failure.jpg', + }); + + expect(response.status).toBe(500); + } finally { + transactionSpy.mockRestore(); + } + + // The newly uploaded replacement file must not linger on disk... + const filesAfter = new Set(await fs.readdir(uploadDir)); + expect(filesAfter.size).toBe(filesBefore.size); + + // ...and the original photo must still be intact and fetchable. + const originalStillServed = await request(app) + .get(`/v1/uploads/${originalFileId}`) + .set('Authorization', authHeader); + expect(originalStillServed.status).toBe(200); + }); + }); + + (useMockDb ? it.skip : it)( + 'deletes the orphaned upload file when an idempotent duplicate request is skipped', + async () => { + const uploadDir = path.resolve(env.UPLOAD_DIRECTORY); + const idempotencyKey = `orphan-cleanup-${Date.now()}`; + + const filesBefore = new Set(await fs.readdir(uploadDir)); + + const created = await request(app) + .post('/v1/moment-logs') + .set('Authorization', authHeader) + .set('Idempotency-Key', idempotencyKey) + .field('createdAt', new Date().toISOString()) + .field('moodTags', 'fresh') + .field('placeName', '고아 파일 정리 테스트') + .attach('photo', fakeJpegBuffer('fake-image-1'), { + contentType: 'image/jpeg', + filename: 'orphan-first.jpg', + }); + expect(created.status).toBe(201); + + const filesAfterFirst = new Set(await fs.readdir(uploadDir)); + expect(filesAfterFirst.size).toBe(filesBefore.size + 1); + + const duplicate = await request(app) + .post('/v1/moment-logs') + .set('Authorization', authHeader) + .set('Idempotency-Key', idempotencyKey) + .field('createdAt', new Date().toISOString()) + .field('moodTags', 'fresh') + .field('placeName', '고아 파일 정리 테스트(중복)') + .attach('photo', fakeJpegBuffer('fake-image-2'), { + contentType: 'image/jpeg', + filename: 'orphan-duplicate.jpg', + }); + expect(duplicate.status).toBe(201); + expect(duplicate.body.data.id).toBe(created.body.data.id); + + // The duplicate request's upload must not be left behind on disk: the idempotency + // short-circuit skips the DB write, so the file it wrote is never referenced by any + // MomentLog row and should have been cleaned up. + const filesAfterDuplicate = new Set(await fs.readdir(uploadDir)); + expect(filesAfterDuplicate.size).toBe(filesAfterFirst.size); + }, + ); + afterAll(async () => { if (useMockDb) { resetMockDb(); diff --git a/tests/legal-pages.test.ts b/tests/legal-pages.test.ts new file mode 100644 index 0000000..90f9174 --- /dev/null +++ b/tests/legal-pages.test.ts @@ -0,0 +1,53 @@ +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; + +import { createApp } from '../src/app.js'; + +const app = createApp(); + +describe('public legal pages', () => { + it.each([ + ['/legal/privacy', '개인정보 처리방침'], + ['/legal/terms', '서비스 이용약관'], + ['/support', '고객지원'], + ])('serves %s without authentication', async (path, title) => { + const response = await request(app).get(path); + const headResponse = await request(app).head(path); + + expect(response.status).toBe(200); + 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(headResponse.status).toBe(200); + expect(headResponse.headers['content-type']).toContain('text/html'); + }); + + it('describes the app privacy answers and the data flows implemented by the API', async () => { + const response = await request(app).get('/legal/privacy'); + + expect(response.text).toContain('계정 이름, 이메일 주소, 사용자 ID'); + expect(response.text).toContain('정확한 위도·경도'); + expect(response.text).toContain('사용자 콘텐츠'); + expect(response.text).toContain('제품 상호작용 정보'); + expect(response.text).toContain('Soundlog 계정과 연결'); + expect(response.text).toContain('추적하지 않습니다'); + expect(response.text).toContain('백그라운드 위치 추적은 사용하지 않습니다'); + expect(response.text).toContain('Nominatim(OpenStreetMap)'); + expect(response.text).toContain('한국관광공사 공공데이터 API'); + expect(response.text).toContain('외부 음악 링크'); + expect(response.text).toContain('영상 업로드 기능을 제공하지 않습니다'); + }); + + it('keeps the public pages scoped to native-app support and legal notices', async () => { + const [privacy, support, terms] = await Promise.all([ + request(app).get('/legal/privacy'), + request(app).get('/support'), + request(app).get('/legal/terms'), + ]); + + expect(privacy.text).toContain('공개 법적 고지'); + expect(support.text).toContain('별도의 웹 서비스나 웹 계정 기능을 제공하지 않습니다'); + expect(terms.text).toContain('별도의 웹 서비스를 제공하지 않습니다'); + }); +}); diff --git a/tests/security-hardening.test.ts b/tests/security-hardening.test.ts new file mode 100644 index 0000000..2691e83 --- /dev/null +++ b/tests/security-hardening.test.ts @@ -0,0 +1,175 @@ +import request from 'supertest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +/** + * These tests each need a distinct process.env / module graph (different + * NODE_ENV or rate-limit config), so every test resets the module cache and + * re-imports src/app.js fresh instead of sharing the app instance used by + * tests/api.test.ts. + */ + +// Only snapshot/restore the specific keys these tests mutate. DATABASE_URL +// and JWT_SECRET (loaded once via `dotenv/config`) must be left untouched: +// once cleared, a re-import cannot reliably reload them (dotenv's own +// module-level state isn't reset by vi.resetModules()). +const MUTATED_KEYS = [ + 'NODE_ENV', + 'AUTH_RATE_LIMIT_ENABLED', + 'AUTH_RATE_LIMIT_MAX', + 'AUTH_RATE_LIMIT_WINDOW_MS', + 'AUTH_RATE_LIMIT_IP_MAX', + 'AUTH_RATE_LIMIT_IP_WINDOW_MS', + 'ML_RECOMMENDATION_API_URL', +] as const; +const originalEnv = Object.fromEntries( + MUTATED_KEYS.map((key) => [key, process.env[key]]), +); + +function restoreEnv() { + for (const key of MUTATED_KEYS) { + const value = originalEnv[key]; + + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +async function freshApp() { + vi.resetModules(); + const { createApp } = await import('../src/app.js'); + return createApp(); +} + +describe('production hardening: dev DB test route', () => { + afterEach(() => { + restoreEnv(); + }); + + it('does not register the dev DB test route in production', async () => { + process.env.NODE_ENV = 'production'; + + const app = await freshApp(); + const response = await request(app).post('/v1/dev/db-test-records').send({}); + + expect(response.status).toBe(404); + expect(response.body.error.code).toBe('NOT_FOUND'); + }); + + it('requires auth for the dev DB test route outside production', async () => { + process.env.NODE_ENV = 'test'; + + const app = await freshApp(); + const response = await request(app) + .post('/v1/dev/db-test-records') + .send({ label: 'no-auth' }); + + expect(response.status).toBe(401); + expect(response.body.error.code).toBe('UNAUTHORIZED'); + }); +}); + +describe('production hardening: ML recommendation transport', () => { + afterEach(() => { + restoreEnv(); + }); + + it('disables a plaintext ML endpoint in production', async () => { + process.env.NODE_ENV = 'production'; + process.env.ML_RECOMMENDATION_API_URL = 'http://211.188.54.204:8000/recommend'; + + vi.resetModules(); + const { env } = await import('../src/config/env.js'); + + expect(env.ML_RECOMMENDATION_API_URL).toBeUndefined(); + }); + + it('treats an empty ML endpoint as disabled in production', async () => { + process.env.NODE_ENV = 'production'; + process.env.ML_RECOMMENDATION_API_URL = ''; + + vi.resetModules(); + const { env } = await import('../src/config/env.js'); + + expect(env.ML_RECOMMENDATION_API_URL).toBeUndefined(); + }); + + it('keeps an HTTPS ML endpoint in production', async () => { + process.env.NODE_ENV = 'production'; + process.env.ML_RECOMMENDATION_API_URL = 'https://ml.soundlog.shop/recommend'; + + vi.resetModules(); + const { env } = await import('../src/config/env.js'); + + expect(env.ML_RECOMMENDATION_API_URL).toBe('https://ml.soundlog.shop/recommend'); + }); +}); + +describe('auth rate limiting', () => { + afterEach(() => { + restoreEnv(); + }); + + it('returns 429 once the configured auth rate limit is exceeded', async () => { + process.env.NODE_ENV = 'test'; + process.env.AUTH_RATE_LIMIT_ENABLED = 'true'; + process.env.AUTH_RATE_LIMIT_MAX = '2'; + process.env.AUTH_RATE_LIMIT_WINDOW_MS = '60000'; + + const app = await freshApp(); + const credentials = { email: 'rate-limit-test@soundlog.test', password: 'wrong-password' }; + + const first = await request(app).post('/v1/auth/login').send(credentials); + const second = await request(app).post('/v1/auth/login').send(credentials); + const third = await request(app).post('/v1/auth/login').send(credentials); + + expect(first.status).not.toBe(429); + expect(second.status).not.toBe(429); + expect(third.status).toBe(429); + expect(third.body.error.code).toBe('TOO_MANY_REQUESTS'); + }); + + it('returns 429 for credential stuffing across many emails from one IP', async () => { + process.env.NODE_ENV = 'test'; + process.env.AUTH_RATE_LIMIT_ENABLED = 'true'; + // Account limit set high so it never trips here — each request below uses + // a different email, so only the per-IP limiter can be what catches this. + process.env.AUTH_RATE_LIMIT_MAX = '1000'; + process.env.AUTH_RATE_LIMIT_WINDOW_MS = '60000'; + process.env.AUTH_RATE_LIMIT_IP_MAX = '2'; + process.env.AUTH_RATE_LIMIT_IP_WINDOW_MS = '60000'; + + const app = await freshApp(); + + const first = await request(app) + .post('/v1/auth/login') + .send({ email: 'stuffing-1@soundlog.test', password: 'wrong-password' }); + const second = await request(app) + .post('/v1/auth/login') + .send({ email: 'stuffing-2@soundlog.test', password: 'wrong-password' }); + const third = await request(app) + .post('/v1/auth/login') + .send({ email: 'stuffing-3@soundlog.test', password: 'wrong-password' }); + + expect(first.status).not.toBe(429); + expect(second.status).not.toBe(429); + expect(third.status).toBe(429); + expect(third.body.error.code).toBe('TOO_MANY_REQUESTS'); + }); + + it('does not rate limit auth endpoints under the default test configuration', async () => { + process.env.NODE_ENV = 'test'; + delete process.env.AUTH_RATE_LIMIT_ENABLED; + + const app = await freshApp(); + const credentials = { email: 'no-rate-limit-test@soundlog.test', password: 'wrong-password' }; + + for (let i = 0; i < 5; i += 1) { + // eslint-disable-next-line no-await-in-loop + const response = await request(app).post('/v1/auth/login').send(credentials); + expect(response.status).not.toBe(429); + } + }); +});