From c9b0eaed26e2a9533bc15ef50251e1177a79a6f0 Mon Sep 17 00:00:00 2001 From: bluebamus Date: Sun, 9 Aug 2026 15:34:42 +0000 Subject: [PATCH 01/12] docs: add sentry remediation plan --- docs/ai-docs/README.md | 27 + docs/ai-docs/handoff/05-agent-handoff.md | 56 ++ .../ai-docs/plans/04-remediation-work-plan.md | 138 ++++ docs/ai-docs/reports/00-total-report.md | 98 +++ .../ai-docs/reviews/01-sentry-error-review.md | 139 ++++ .../security/02-nginx-php-scan-blocking.md | 100 +++ .../03-app-layer-access-guard-design.md | 112 +++ ...onHardwareStats.MultipleObjectsReturned.md | 139 ++++ ...tionMethodStats.MultipleObjectsReturned.md | 163 +++++ docs/error/DataError.md | 657 ++++++++++++++++++ ...[email]']: HTTP Error 401: Unauthorized.md | 31 + ...ded\",\"field\":null,\"help\":null}]}'.md" | 33 + {doc => docs}/helpers_code/__init__.py | 0 .../helpers_code/models/base_models.py | 0 .../helpers_code/translate_sample.html | 0 {doc => docs}/helpers_code/urls_ex.py | 0 .../ipynb_files/email-validator.ipynb | 0 {doc => docs}/ipynb_files/orm_base.ipynb | 0 .../orm_check_interval_3day_users.ipynb | 0 {doc => docs}/note_commands.md | 0 {doc => docs}/reference.md | 0 21 files changed, 1693 insertions(+) create mode 100644 docs/ai-docs/README.md create mode 100644 docs/ai-docs/handoff/05-agent-handoff.md create mode 100644 docs/ai-docs/plans/04-remediation-work-plan.md create mode 100644 docs/ai-docs/reports/00-total-report.md create mode 100644 docs/ai-docs/reviews/01-sentry-error-review.md create mode 100644 docs/ai-docs/security/02-nginx-php-scan-blocking.md create mode 100644 docs/ai-docs/security/03-app-layer-access-guard-design.md create mode 100644 docs/error/ConnectionHardwareStats.MultipleObjectsReturned.md create mode 100644 docs/error/ConnectionMethodStats.MultipleObjectsReturned.md create mode 100644 docs/error/DataError.md create mode 100644 docs/error/Error sending email to ['[email]']: HTTP Error 401: Unauthorized.md create mode 100644 "docs/error/Failed to send email, error: HTTP Error 401: Unauthorized, response body: b'{\"errors\":[{\"message\":\"Maximum credits exceeded\",\"field\":null,\"help\":null}]}'.md" rename {doc => docs}/helpers_code/__init__.py (100%) rename {doc => docs}/helpers_code/models/base_models.py (100%) rename {doc => docs}/helpers_code/translate_sample.html (100%) rename {doc => docs}/helpers_code/urls_ex.py (100%) rename {doc => docs}/ipynb_files/email-validator.ipynb (100%) rename {doc => docs}/ipynb_files/orm_base.ipynb (100%) rename {doc => docs}/ipynb_files/orm_check_interval_3day_users.ipynb (100%) rename {doc => docs}/note_commands.md (100%) rename {doc => docs}/reference.md (100%) diff --git a/docs/ai-docs/README.md b/docs/ai-docs/README.md new file mode 100644 index 0000000..73dae0d --- /dev/null +++ b/docs/ai-docs/README.md @@ -0,0 +1,27 @@ +# AI Docs Index + +이 폴더는 `docs/error`의 Sentry 이슈를 Codex와 Claude가 함께 검토하고 후속 구현을 진행하기 위한 작업 문서 모음이다. + +## 문서 구조 + +- [reports/00-total-report.md](reports/00-total-report.md): 전체 요약, 우선순위, 승인/검토 포인트 +- [reviews/01-sentry-error-review.md](reviews/01-sentry-error-review.md): Sentry 이슈별 원인 검수 +- [security/02-nginx-php-scan-blocking.md](security/02-nginx-php-scan-blocking.md): PHP/WordPress 스캔 트래픽 nginx 1차 차단 방안 +- [security/03-app-layer-access-guard-design.md](security/03-app-layer-access-guard-design.md): Django 애플리케이션 2차 접근 차단 설계 +- [plans/04-remediation-work-plan.md](plans/04-remediation-work-plan.md): 구현 작업 계획서 +- [handoff/05-agent-handoff.md](handoff/05-agent-handoff.md): Codex/Claude 공용 핸드오프 체크리스트 + +## 기준 자료 + +- `docs/error/DataError.md` +- `docs/error/ConnectionHardwareStats.MultipleObjectsReturned.md` +- `docs/error/ConnectionMethodStats.MultipleObjectsReturned.md` +- `docs/error/Error sending email to ['[email]']: HTTP Error 401: Unauthorized.md` +- `docs/error/Failed to send email, error: HTTP Error 401: Unauthorized, response body: b'{"errors":[{"message":"Maximum credits exceeded","field":null,"help":null}]}'.md` + +## 검토 기준 + +- nginx에서 명확한 비서비스 트래픽은 애플리케이션 도달 전에 차단한다. +- Django에서는 nginx 누락, 내부 우회, 설정 실수에 대비해 조기 반환 방어선을 둔다. +- Sentry에 기록된 런타임 예외는 입력 검증, DB 제약, 데이터 정리, 외부 서비스 상태를 분리해 해결한다. +- 구현자는 이 문서를 기준으로 코드 변경 전 데이터 백업/마이그레이션 영향도를 재확인한다. diff --git a/docs/ai-docs/handoff/05-agent-handoff.md b/docs/ai-docs/handoff/05-agent-handoff.md new file mode 100644 index 0000000..7f76230 --- /dev/null +++ b/docs/ai-docs/handoff/05-agent-handoff.md @@ -0,0 +1,56 @@ +# Agent Handoff + +## 현재 상태 + +- `docs/error`에 Sentry export 5개가 있다. +- `docs/error`는 현재 git 기준 미추적 상태로 보인다. +- 이 문서 세트는 `docs/ai-docs`에 새로 작성되었다. +- 코드 변경은 아직 하지 않았다. + +## 주요 코드 위치 + +- 통계 미들웨어: `custom_middlewares/middlewares/statistics.py` +- 통계 모델: `custom_middlewares/models.py` +- 통계 admin 등록: `home/admin/default_admin.py` +- 문의 모델: `portfolio/models.py` +- 문의 뷰: `portfolio/views.py` +- 문의 템플릿: `templates/portfolio/portfolio.html` +- 메일 스레드 발송: `utils/email/async_send_email.py` +- 공통 middleware 설정: `config/settings/base.py` +- 운영 `ALLOWED_HOSTS`: `config/settings/prod.py` + +## 다음 에이전트가 바로 확인할 것 + +1. 운영 nginx 설정 파일은 이 저장소에 없다. 배포 환경에서 별도로 확인해야 한다. +2. Django 마이그레이션 디렉터리가 현재 검색에서 보이지 않았다. 실제 migration 정책을 먼저 확인한다. +3. `custom_middlewares.models`의 통계 모델은 `Meta.app_label = "home"`을 사용한다. migration 생성 전 Django app label과 migration module을 반드시 검증한다. +4. `GetInTouchView.post()`에는 첫 번째 return 이후 dead code가 여러 번 반복된다. 기능 수정 시 함께 정리한다. +5. `utils/email/async_send_email.py`는 호출자에게 발송 결과를 반환하지 않는다. 메일 실패 처리 설계 시 이 제약을 반영한다. + +## 구현 순서 권장 + +1. nginx 1차 차단 적용 +2. Django 2차 차단 미들웨어 추가 +3. 통계 중복 데이터 정리 +4. 통계 모델/미들웨어 구조 개선 +5. 문의 폼 검증 개선 +6. 메일 실패 처리 개선 +7. Sentry 필터와 테스트 보강 + +## 열려 있는 결정 + +- nginx 차단 응답: `444` 유지 또는 `404`/`403` 대체 +- 전화번호 입력 정책: 국내 휴대폰만 허용 또는 국제 번호 허용 +- 통계의 봇 처리: 제외, 별도 bot 카운트, 현행 유지 +- `GetInTouchLog.state` 의미: 접수 성공인지 메일 발송 성공인지 +- 메일 비동기 처리: 현행 스레드 유지, Celery 전환, 동기 발송 선택지 추가 + +## 검증 명령 후보 + +```bash +python manage.py check --settings=config.settings.dev +python manage.py test home.tests.test_error --settings=config.settings.test +python manage.py test portfolio --settings=config.settings.test +``` + +환경변수와 DB 의존성이 있으므로 로컬에서 바로 실행되지 않을 수 있다. 실패 시 누락된 환경변수와 DB 연결 설정을 먼저 확인한다. diff --git a/docs/ai-docs/plans/04-remediation-work-plan.md b/docs/ai-docs/plans/04-remediation-work-plan.md new file mode 100644 index 0000000..28e29cf --- /dev/null +++ b/docs/ai-docs/plans/04-remediation-work-plan.md @@ -0,0 +1,138 @@ +# Remediation Work Plan + +## Phase 0: 운영 차단 + +담당 후보: 운영자 또는 배포 담당 에이전트 + +1. nginx 설정에 [../security/02-nginx-php-scan-blocking.md](../security/02-nginx-php-scan-blocking.md)의 PHP/WordPress 차단 location을 추가한다. +2. default server로 미등록 Host 접근을 차단한다. +3. `nginx -t` 후 reload한다. +4. curl과 access log로 `.php` 요청이 gunicorn까지 도달하지 않는지 확인한다. + +완료 기준: + +- gunicorn access log에서 신규 `.php` 요청이 사라진다. +- Sentry의 PHP 스캔 관련 이벤트가 감소한다. + +## Phase 1: Django 2차 차단 미들웨어 + +담당 후보: Codex + +1. `custom_middlewares/middlewares/access_guard.py`를 추가한다. +2. `BlockSuspiciousPathMiddleware`를 구현한다. +3. `config/settings/base.py`의 `MIDDLEWARE`에서 통계 미들웨어보다 앞에 삽입한다. +4. 단위 테스트를 추가한다. + +테스트 후보: + +- `GET /wp.php` +- `GET /site/phpinfo.php` +- `GET /bbs/board.php` +- `GET /portfolio/` + +완료 기준: + +- 차단 URL은 조기 404 또는 설정 status를 반환한다. +- 차단 URL 요청은 통계 row를 변경하지 않는다. +- 정상 URL의 기존 테스트가 통과한다. + +## Phase 2: 통계 테이블 중복 정리 및 모델 개선 + +담당 후보: Claude 또는 Codex + +1. 운영 DB에서 중복 현황을 조회한다. + +```sql +SELECT DATE(created_at) AS stat_date, COUNT(*) +FROM connection_method_stats +GROUP BY DATE(created_at) +HAVING COUNT(*) > 1; + +SELECT DATE(created_at) AS stat_date, COUNT(*) +FROM connection_hardware_stats +GROUP BY DATE(created_at) +HAVING COUNT(*) > 1; +``` + +2. 날짜별 중복 row의 카운트를 합산하고 대표 row 1개만 남기는 data migration 또는 운영 스크립트를 만든다. +3. 모델에 명시적 일자 필드를 추가한다. + +```python +stat_date = models.DateField(unique=True, db_index=True) +``` + +4. 통계 미들웨어 조회키를 `created_at__date`에서 `stat_date`로 변경한다. +5. 동시 생성 경합은 유니크 제약과 `IntegrityError` retry로 처리한다. + +주의: + +- 현재 모델은 `custom_middlewares/models.py`에 있으나 `Meta.app_label = "home"`으로 DB app label을 `home`으로 잡는다. +- 마이그레이션 파일 위치와 앱 라벨을 실제 Django가 어떻게 인식하는지 `makemigrations --dry-run`으로 확인해야 한다. +- 운영 데이터 백업 후 적용한다. + +완료 기준: + +- 같은 날짜 row가 1개만 존재한다. +- 동시 요청 테스트에서 중복 row가 생기지 않는다. +- 기존 admin 통계 화면이 정상 동작한다. + +## Phase 3: 문의 폼 검증 개선 + +담당 후보: Codex + +1. `portfolio/forms.py`를 추가하거나 기존 패턴에 맞는 위치에 `GetInTouchForm`을 만든다. +2. 입력 필드 정책을 정의한다. + +| 필드 | 정책 | +| --- | --- | +| name | 필수, trim, 최대 300자 | +| emailfrom | 필수, EmailField, DNS 검증은 timeout/fallback 고려 | +| number | 선택, 최대 16자, 값이 있으면 phone validator 적용 | +| subject | 필수, trim, 최대 300자 | +| message | 필수, trim, 최대 길이 정책 필요 | + +3. `GetInTouchView.post()`를 form 기반으로 단순화한다. +4. `check_email_validation_with_dns()`의 `is_valid` 초기화 문제를 수정한다. +5. 도달 불가능한 중복 코드를 제거한다. +6. 스팸성 요청에 대해 rate limit 또는 captcha 적용을 검토한다. 이미 운영 `INSTALLED_APPS`에 `captcha`가 추가되어 있으므로 활용 가능성을 확인한다. + +완료 기준: + +- 16자 초과 전화번호 입력은 DB insert 전에 form error로 종료된다. +- Sentry의 `DataError value too long for type character varying(16)`가 재발하지 않는다. +- 정상 문의는 저장과 사용자 메시지가 정상 동작한다. + +## Phase 4: 메일 발송 실패 처리 + +담당 후보: Claude 또는 Codex + +1. 운영 SendGrid 상태를 확인한다. + - API key 유효성 + - sender/domain 인증 + - 크레딧/과금 한도 + - sandbox mode 여부 +2. `utils/email/async_send_email.py`의 스레드 기반 발송 정책을 재검토한다. +3. Celery task 기반으로 전환하거나, 최소한 발송 결과를 기록하는 wrapper를 만든다. +4. `GetInTouchLog.state`의 의미를 명확히 한다. + - 후보 A: 문의 접수 여부 + - 후보 B: 메일 발송 성공 여부 + - 후보 C: `status` enum으로 `received`, `queued`, `sent`, `failed` +5. Sentry logging에서 recipient PII를 마스킹한다. + +완료 기준: + +- 메일 서비스 장애가 사용자 성공 메시지와 혼동되지 않는다. +- 발송 실패가 운영자가 볼 수 있는 상태로 남는다. +- Sentry에 민감정보가 원문으로 남지 않는다. + +## Phase 5: 관측 및 회귀 방지 + +1. Sentry ignore/filter 정책을 정리한다. +2. PHP scan 차단량을 nginx access log 또는 별도 metric으로 집계할지 결정한다. +3. 통계 미들웨어 테스트를 CI에 포함한다. +4. 문의 폼 악성 입력 테스트를 추가한다. + +완료 기준: + +- 실제 장애와 스캔 노이즈가 분리된다. +- 같은 유형의 Sentry 이벤트가 재발하면 담당자가 원인을 바로 추적할 수 있다. diff --git a/docs/ai-docs/reports/00-total-report.md b/docs/ai-docs/reports/00-total-report.md new file mode 100644 index 0000000..1fcaf75 --- /dev/null +++ b/docs/ai-docs/reports/00-total-report.md @@ -0,0 +1,98 @@ +# Total Report + +작성일: 2026-08-09 + +## 결론 + +`docs/error`의 Sentry 이슈는 세 종류로 정리된다. + +1. PHP/WordPress 탐색성 요청이 Django까지 도달했다. +2. 일별 접속 통계 테이블에 같은 날짜의 중복 row가 생겨 `get_or_create(created_at__date=today)`가 실패했다. +3. 포트폴리오 문의 폼에서 검증되지 않은 긴 입력과 외부 메일 서비스 크레딧/인증 문제가 섞여 Sentry 에러가 발생했다. + +가장 먼저 적용할 대응은 nginx에서 `.php`, WordPress 경로, phpinfo 탐색 요청을 `444`로 끊는 것이다. 그 다음 Django 최상단 미들웨어에서 같은 패턴을 2차로 조기 차단해 통계 미들웨어, URL resolver, Sentry까지 불필요하게 흘러가지 않도록 한다. + +## 우선순위 + +| 우선순위 | 작업 | 이유 | +| --- | --- | --- | +| P0 | nginx PHP/WordPress 스캔 차단 | 애플리케이션 비용과 Sentry 노이즈를 즉시 줄인다. | +| P0 | 통계 테이블 중복 데이터 정리 | 현재 중복 row가 존재하면 매 요청마다 `MultipleObjectsReturned`가 재발한다. | +| P1 | 통계 모델을 일자 단위 유니크 구조로 변경 | 중복 재발을 구조적으로 막는다. | +| P1 | 문의 폼 입력 검증 및 로그 저장 순서 개선 | `DataError`와 가짜 성공 로그를 막는다. | +| P1 | SendGrid 크레딧/키 상태 점검 및 메일 실패 처리 개선 | 외부 서비스 실패를 사용자 성공 처리와 분리한다. | +| P2 | Sentry 필터링/태그 정리 | 악성 스캔과 실제 장애를 분리해 관측 품질을 높인다. | + +## 확인된 이슈별 요약 + +### PHP/WordPress 스캔 트래픽 + +- Sentry 문서: + - `ConnectionHardwareStats.MultipleObjectsReturned.md`: `/wp.php` + - `ConnectionMethodStats.MultipleObjectsReturned.md`: `/bbs/board.php` + - 이메일 401 문서 breadcrumbs: `/public_html/phpinfo.php`, `/site/phpinfo.php`, `/wp-admin/phpinfo.php`, `/includes/phpinfo.php` +- 문제: + - 서비스와 무관한 `.php`/WordPress 탐색 요청이 Django까지 도달했다. + - 이 요청도 통계 미들웨어가 처리하면서 DB write와 Sentry 이벤트를 유발했다. +- 권장: + - nginx `server` 블록에서 `return 444`로 즉시 차단한다. + - Django에는 fallback 조기 차단 미들웨어를 통계 미들웨어보다 앞에 둔다. + +### 통계 중복 row + +- 관련 코드: + - `custom_middlewares/middlewares/statistics.py` + - `custom_middlewares/models.py` +- 문제: + - `created_at`은 `DateTimeField(auto_now_add=True)`이고 일자 유니크 제약이 없다. + - `get_or_create(created_at__date=today)`는 실제 생성 시 `created_at__date` 값을 넣을 수 없고, 동시 요청 또는 과거 로직으로 같은 날짜 row가 여러 개 생길 수 있다. + - 중복이 한 번 생기면 이후 `get_or_create()`의 내부 `get()`이 계속 `MultipleObjectsReturned`를 발생시킨다. +- 권장: + - `stat_date = DateField(unique=True)`를 추가해 일자 단위 집계키로 사용한다. + - 기존 중복 row를 날짜별로 합산해 하나로 정리하는 data migration 또는 운영 스크립트를 먼저 실행한다. + +### 문의 폼 DataError + +- Sentry 문서: `DataError.md` +- 관련 코드: + - `portfolio/views.py` + - `portfolio/models.py` + - `templates/portfolio/portfolio.html` +- 문제: + - `GetInTouchLog.phone_number`는 `max_length=16`인데 Sentry 입력값은 랜덤 문자열로 16자를 초과한다. + - 현재 뷰는 `number` 길이와 휴대폰 형식 검증을 저장 전에 명확히 수행하지 않는다. + - `GetInTouchView.check_email_validation_with_dns()`의 예외 경로에서 `is_valid`가 초기화되지 않을 수 있다. + - `GetInTouchView.post()` 하단에는 도달 불가능한 중복 코드가 여러 번 남아 있다. +- 권장: + - `forms.Form` 또는 `ModelForm`으로 문의 입력 검증을 이동한다. + - `phone_number`는 optional이면 빈 값 허용, 입력 시 길이/형식 검증을 엄격히 적용한다. + - 저장은 메일 큐 등록/발송 결과 정책과 분리해 `state` 의미를 재정의한다. + +### 이메일 401 / Maximum credits exceeded + +- Sentry 문서: + - `Error sending email to ['[email]']: HTTP Error 401: Unauthorized.md` + - `Failed to send email, error: HTTP Error 401: Unauthorized, response body: ... Maximum credits exceeded ... .md` +- 관련 코드: + - `utils/email/async_send_email.py` + - `config/settings/base.py` + - `config/settings/sub_settings/email/sendgrid.py` +- 문제: + - SendGrid 또는 연결된 메일 백엔드가 인증/크레딧 문제로 실패하고 있다. + - 현재 `send_mail()`은 스레드를 시작하고 즉시 반환하므로 뷰에서 실패 여부를 알 수 없다. + - 문의 폼은 메일 실패 가능성과 무관하게 성공 메시지를 보여준다. +- 권장: + - 운영 메일 벤더 상태와 과금/크레딧/키 권한을 즉시 확인한다. + - 비동기 스레드 대신 Celery 작업 또는 동기 결과 반환 래퍼로 실패 상태를 추적한다. + - 실패 이벤트에는 recipient 원문을 마스킹하고, 사용자에게는 접수/발송 상태를 분리해 안내한다. + +## 승인 필요 사항 + +- nginx 설정에 `return 444` 정책을 적용할지, 또는 운영 표준상 `403`/`404`로 대체할지 결정해야 한다. +- 통계 테이블 중복 row 정리 시 날짜별 카운트를 합산할지, 최신 row만 보존할지 결정해야 한다. 권장은 합산이다. +- 문의 폼 전화번호 정책을 국내 휴대폰 형식만 허용할지, 국제 전화번호까지 허용할지 결정해야 한다. +- 메일 실패 시 사용자 메시지를 "접수 완료, 발송 지연"으로 바꿀지, 실패로 명확히 안내할지 결정해야 한다. + +## 다음 작업 + +구현자는 [../plans/04-remediation-work-plan.md](../plans/04-remediation-work-plan.md)의 단계 순서대로 진행한다. nginx 반영은 앱 배포와 별도 운영 작업이므로, 적용 후 access log와 Sentry 발생량을 함께 확인한다. diff --git a/docs/ai-docs/reviews/01-sentry-error-review.md b/docs/ai-docs/reviews/01-sentry-error-review.md new file mode 100644 index 0000000..6486654 --- /dev/null +++ b/docs/ai-docs/reviews/01-sentry-error-review.md @@ -0,0 +1,139 @@ +# Sentry Error Review + +## 조사 범위 + +검토 대상은 `docs/error`의 5개 Sentry export 문서와 현재 Django 코드다. + +## PYTHON-DJANGO-7H: ConnectionHardwareStats.MultipleObjectsReturned + +- 파일: `docs/error/ConnectionHardwareStats.MultipleObjectsReturned.md` +- 발생일: 2026-08-05 23:32:04 UTC +- 요청: `GET /wp.php` +- 사용자 IP: `20.100.178.111` +- 관련 코드: `custom_middlewares/middlewares/statistics.py` + +### 원인 + +`ConnectionHardwareStatsMiddleware`가 모든 비-admin 요청에서 일별 하드웨어 통계를 갱신한다. 현재 로직은 다음 형태다. + +```python +ConnectionHardwareStats.objects.select_for_update().get_or_create( + created_at__date=today +) +``` + +하지만 `created_at`은 자동 생성 datetime이고, 모델에는 일자 단위 유니크 키가 없다. 이미 같은 날짜 row가 2개 존재하는 상태에서 `get_or_create()` 내부 `get()`이 `MultipleObjectsReturned`를 발생시켰다. + +### 영향 + +- PHP 스캔 요청 하나도 DB write 경로를 탄다. +- 같은 날짜 중복 row가 남아 있으면 정상 사용자 요청에서도 계속 재발할 수 있다. +- 통계 미들웨어가 URL 처리보다 앞서 실행되므로 404가 될 요청도 통계 DB를 건드린다. + +### 해결 방향 + +- nginx에서 `/wp.php`, `.php` 요청을 우선 차단한다. +- Django 조기 차단 미들웨어를 통계 미들웨어보다 앞에 둔다. +- 통계 모델에 `stat_date` 또는 동등한 일자 집계키를 추가하고 유니크 제약을 둔다. +- 기존 중복 데이터를 날짜별로 합산 정리한다. + +## PYTHON-DJANGO-7G: ConnectionMethodStats.MultipleObjectsReturned + +- 파일: `docs/error/ConnectionMethodStats.MultipleObjectsReturned.md` +- 발생일: 2026-08-05 23:37:09 UTC +- 요청: `GET /bbs/board.php?bo_table=free&wr_id=1718` +- user-agent: `ClaudeBot/1.0` +- 관련 코드: `custom_middlewares/middlewares/statistics.py` + +### 원인 + +하드웨어 통계와 같은 구조적 문제다. `ConnectionMethodStats` 역시 `created_at__date=today`로 조회하지만 날짜별 유니크 제약이 없다. + +### 추가 관찰 + +- `/bbs/board.php`는 그누보드/게시판 계열 탐색성 URL로 보이며 현재 Django 서비스 URL과 무관하다. +- `ConnectionMethodStatsMiddleware.__call__()`는 `admin` 문자열만 제외하고 모든 user-agent 요청을 집계한다. +- 봇 요청도 운영체제 `oth`로 분류되어 실제 사용자 통계를 왜곡할 수 있다. + +### 해결 방향 + +- 통계 집계 대상에서 차단 요청, 정적 파일, 헬스체크, 봇을 분리할지 정책화한다. +- 통계 갱신은 `update_or_create(stat_date=today, defaults=...)`보다 `filter(stat_date=today).update(...)` 후 없으면 생성하는 패턴 또는 DB upsert를 고려한다. +- 동시성은 DB 유니크 제약으로 최종 보장한다. + +## PYTHON-DJANGO-7B: DataError value too long for type character varying(16) + +- 파일: `docs/error/DataError.md` +- 발생일: 2026-07-12 11:28:03 UTC +- 요청: `POST /portfolio/mail` +- 사용자 IP: `185.220.101.168` +- 관련 코드: + - `portfolio/views.py` + - `portfolio/models.py` + +### 원인 + +`GetInTouchLog.phone_number`는 `max_length=16`이고 RegexValidator도 정의되어 있다. 하지만 현재 뷰는 `number = request.POST.get("number", "")` 후 저장 전 `full_clean()` 또는 form validation을 실행하지 않는다. 결과적으로 16자를 초과한 랜덤 문자열이 DB insert까지 도달했다. + +Sentry 변수의 `params`에는 다음처럼 무작위 입력이 포함되어 있다. + +- `name`: 랜덤 문자열 +- `email`: Gmail 주소 +- `phone_number`: 16자 초과 랜덤 문자열 +- `subject`: 랜덤 문자열 +- `message`: 랜덤 문자열 + +정상 문의라기보다 폼 스팸/자동화 요청에 가깝다. + +### 추가 문제 + +- `check_email_validation_with_dns()`는 예외 경로에서 `is_valid`가 정의되지 않은 채 반환될 수 있다. +- `post()` 하단에는 첫 번째 `return redirect(...)` 이후 도달 불가능한 중복 저장 코드가 반복된다. +- `emailto` hidden input은 읽지만 실제 발송 대상은 `settings.DEFAULT_FROM_EMAIL`로 고정되어 있다. +- 메일 전송 실패와 로그 저장 성공 여부가 분리되어 있지 않다. + +### 해결 방향 + +- 문의 폼을 `forms.Form` 또는 `ModelForm`으로 전환한다. +- `name`, `subject`, `message` 길이 제한과 strip 처리를 추가한다. +- `phone_number`는 optional 입력으로 두되, 값이 있으면 모델과 동일한 validator를 뷰 계층에서 실행한다. +- 모델 저장 전 `form.is_valid()` 또는 `model.full_clean()`을 강제한다. +- 중복 dead code를 제거한다. + +## PYTHON-DJANGO-7C / 7D: Email 401 Unauthorized + +- 파일: + - `docs/error/Error sending email to ['[email]']: HTTP Error 401: Unauthorized.md` + - `docs/error/Failed to send email, error: HTTP Error 401: Unauthorized, response body: ... Maximum credits exceeded ... .md` +- 발생일: 2026-07-12 11:28:05 UTC +- 관련 코드: + - `utils/email/async_send_email.py` + - `config/settings/base.py` + - `config/settings/sub_settings/email/sendgrid.py` + +### 원인 + +메일 백엔드가 SendGrid로 설정되어 있고, Sentry 메시지에 `Maximum credits exceeded`가 포함되어 있다. 이는 코드 예외라기보다 외부 메일 서비스의 계정/크레딧/권한 상태 문제다. + +### 코드상 문제 + +`utils/email/async_send_email.py`의 `send_mail()`은 스레드를 시작하고 종료한다. 스레드 내부 예외는 logger에만 남으며 호출자는 성공/실패를 알 수 없다. + +```python +EmailThread(...).start() +``` + +따라서 `GetInTouchView.post()`는 메일 실패 여부와 무관하게 `messages.success()`를 반환할 수 있다. + +### 해결 방향 + +- 운영 환경의 SendGrid API key, sender 인증, 크레딧/과금 상태를 점검한다. +- 메일 발송을 Celery task로 이관하고 task 상태를 기록하거나, 최소한 동기 발송 옵션을 둔다. +- 사용자-facing 메시지는 "문의 접수"와 "메일 발송 성공"을 분리한다. +- Sentry 로그에서 이메일 주소 등 PII를 마스킹한다. + +## 공통 리스크 + +- 현재 `docs/error` 자체가 git 미추적 상태다. 문서화 및 구현 전 커밋 범위를 명확히 해야 한다. +- 운영 DB에 이미 중복 데이터가 있으므로 코드만 수정하면 마이그레이션이 실패하거나 중복 문제가 남을 수 있다. +- nginx 차단 없이 앱 코드만 수정하면 불필요한 요청량과 Sentry 노이즈가 계속 발생한다. diff --git a/docs/ai-docs/security/02-nginx-php-scan-blocking.md b/docs/ai-docs/security/02-nginx-php-scan-blocking.md new file mode 100644 index 0000000..7225d6c --- /dev/null +++ b/docs/ai-docs/security/02-nginx-php-scan-blocking.md @@ -0,0 +1,100 @@ +# Nginx PHP Scan Blocking + +## 목표 + +PHP/WordPress/phpinfo 탐색 요청을 Django, gunicorn, Sentry까지 보내지 않고 nginx에서 1차로 종료한다. + +## 차단 대상 + +Sentry breadcrumbs와 요청 URL 기준으로 다음 패턴은 현재 서비스와 무관하다. + +- `*.php` +- `/wp.php` +- `/wp-admin/...` +- `/wp-content/...` +- `/wp-includes/...` +- `/bbs/board.php` +- `/public_html/phpinfo.php` +- `/site/phpinfo.php` +- `/includes/phpinfo.php` +- 기타 `phpinfo.php`, `xmlrpc.php`, `wp-login.php` + +## 권장 nginx 설정 + +운영 `server` 블록 안에 다음 location을 Django proxy location보다 앞에 둔다. + +```nginx +# PHP, WordPress, phpinfo scanners. This service does not run PHP. +location ~* (^|/)(wp-admin|wp-content|wp-includes)(/|$) { + access_log off; + log_not_found off; + return 444; +} + +location ~* (^|/)(phpinfo|xmlrpc|wp-login|wp-config|wp-cron|wp-load|wp-mail|wp-settings|wp-signup|wp-trackback|wp)\.php$ { + access_log off; + log_not_found off; + return 444; +} + +location ~* \.php(?:/|$) { + access_log off; + log_not_found off; + return 444; +} +``` + +`444`는 nginx 전용 비표준 상태로, 응답 본문 없이 연결을 닫는다. 운영 표준이나 로드밸런서 정책상 `444`를 쓰기 어렵다면 `return 404;` 또는 `return 403;`으로 바꾼다. + +## Host 헤더 차단 + +알 수 없는 도메인 또는 IP 직결 요청도 default server에서 버린다. + +```nginx +server { + listen 80 default_server; + listen 443 ssl default_server; + server_name _; + + access_log off; + log_not_found off; + return 444; +} + +server { + listen 80; + listen 443 ssl; + server_name devspoon.com www.devspoon.com; + + # normal Django proxy settings here +} +``` + +TLS 인증서 설정 구조상 `default_server`에 인증서가 필요할 수 있다. 기존 운영 nginx 구성을 확인한 뒤 적용한다. + +## reverse proxy 사용 시 확인 사항 + +- 로드밸런서 또는 CDN이 앞단에 있으면 실제 nginx까지 Host 헤더가 어떻게 전달되는지 확인한다. +- Cloudflare, ALB, NCP, AWS ELB 등 앞단이 있다면 해당 계층에서도 WAF/rule로 `.php` 차단을 추가하는 것이 좋다. +- proxy upstream으로 넘기기 전에 차단 location이 매칭되는지 `nginx -T`로 최종 설정을 확인한다. + +## 배포 절차 + +1. 운영 nginx 설정 파일에 차단 location을 추가한다. +2. `nginx -t`로 문법 검증을 한다. +3. `nginx -s reload` 또는 systemd reload를 수행한다. +4. 다음 요청으로 확인한다. + +```bash +curl -I http://devspoon.com/wp.php +curl -I http://devspoon.com/site/phpinfo.php +curl -I http://devspoon.com/bbs/board.php +``` + +`444`는 curl에서 `Empty reply from server`로 보일 수 있다. `404`/`403` 정책을 택한 경우 해당 status를 확인한다. + +## 관측 포인트 + +- gunicorn access log에 `.php` 요청이 더 이상 남지 않아야 한다. +- Sentry의 `/wp.php`, `/bbs/board.php`, `/phpinfo.php` 관련 이벤트가 감소해야 한다. +- 정상 URL `/`, `/portfolio/`, `/blog/`는 영향을 받지 않아야 한다. diff --git a/docs/ai-docs/security/03-app-layer-access-guard-design.md b/docs/ai-docs/security/03-app-layer-access-guard-design.md new file mode 100644 index 0000000..f43e238 --- /dev/null +++ b/docs/ai-docs/security/03-app-layer-access-guard-design.md @@ -0,0 +1,112 @@ +# App Layer Access Guard Design + +## 목표 + +nginx에서 놓친 비서비스 요청을 Django 애플리케이션 초입에서 2차로 차단한다. 이 방어선은 nginx의 대체물이 아니라 fallback이다. + +## 중요한 전제 + +Django 애플리케이션은 nginx의 `return 444`처럼 TCP 연결을 즉시 drop할 수 없다. 앱 계층에서는 다음 중 하나를 선택한다. + +- `HttpResponse(status=404)`: 스캐너에게 정보 노출이 적고 일반적이다. +- `HttpResponse(status=403)`: 차단 의도가 명확하지만 스캐너에게 정책 존재를 드러낸다. +- `SuspiciousOperation`: Django 보안 로그와 400 응답으로 연결된다. Sentry 노이즈가 생길 수 있다. + +권장은 `404` 조기 반환이다. 실제 drop은 nginx에서 처리한다. + +## 미들웨어 위치 + +`config/settings/base.py`의 `MIDDLEWARE`에서 통계 미들웨어보다 앞, 가능하면 `SecurityMiddleware` 바로 뒤에 둔다. + +```python +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "custom_middlewares.middlewares.access_guard.BlockSuspiciousPathMiddleware", + ... + "custom_middlewares.middlewares.statistics.ConnectionMethodStatsMiddleware", + "custom_middlewares.middlewares.statistics.ConnectionHardwareStatsMiddleware", +] +``` + +이렇게 해야 차단 요청이 통계 DB write 경로에 들어가지 않는다. + +## 설계안 + +신규 파일 후보: + +- `custom_middlewares/middlewares/access_guard.py` + +설정값 후보: + +- `BLOCK_SUSPICIOUS_PATHS = True` +- `SUSPICIOUS_PATH_RESPONSE_STATUS = 404` +- `SUSPICIOUS_PATH_PATTERNS = [...]` + +예상 구현: + +```python +import re + +from django.conf import settings +from django.http import HttpResponse + + +DEFAULT_SUSPICIOUS_PATH_PATTERNS = [ + r"(^|/)(wp-admin|wp-content|wp-includes)(/|$)", + r"(^|/)(phpinfo|xmlrpc|wp-login|wp-config|wp-cron|wp-load|wp-mail|wp-settings|wp-signup|wp-trackback|wp)\.php$", + r"\.php(?:/|$)", +] + + +class BlockSuspiciousPathMiddleware: + def __init__(self, get_response): + self.get_response = get_response + raw_patterns = getattr( + settings, + "SUSPICIOUS_PATH_PATTERNS", + DEFAULT_SUSPICIOUS_PATH_PATTERNS, + ) + self.patterns = [re.compile(pattern, re.IGNORECASE) for pattern in raw_patterns] + self.enabled = getattr(settings, "BLOCK_SUSPICIOUS_PATHS", True) + self.status = getattr(settings, "SUSPICIOUS_PATH_RESPONSE_STATUS", 404) + + def __call__(self, request): + if self.enabled and self._is_suspicious_path(request.path_info): + return HttpResponse(status=self.status) + return self.get_response(request) + + def _is_suspicious_path(self, path): + return any(pattern.search(path) for pattern in self.patterns) +``` + +## Host 검증과 도메인 무관 접근 + +Django의 `ALLOWED_HOSTS`는 이미 `config/settings/prod.py`에서 환경변수 기반으로 설정된다. Host 헤더가 허용되지 않으면 Django는 `DisallowedHost`를 발생시킨다. + +추가 개선 방향: + +- 운영 `ALLOWED_HOSTS_IP`에 실제 서비스 도메인과 필요한 내부 호스트만 둔다. +- IP 직접 접근을 nginx default server에서 먼저 차단한다. +- 앱 계층에서 별도 Host guard를 추가할 경우 `request.get_host()` 호출 자체가 `DisallowedHost`를 발생시킬 수 있으므로, Sentry 필터와 함께 설계한다. + +권장 순서: + +1. nginx default server로 미등록 Host를 `444` 처리한다. +2. Django `ALLOWED_HOSTS`를 최소화한다. +3. Sentry에서 `DisallowedHost`를 ignore하거나 낮은 레벨로 필터링한다. + +## 통계 미들웨어와의 연계 + +차단 미들웨어가 들어가도 통계 미들웨어 자체는 다음 개선이 필요하다. + +- `admin` 문자열 포함 여부가 아니라 `request.path_info.startswith("/admin/")` 같은 명확한 조건 사용 +- static/media/healthcheck/sitemap/robots 처리 정책 정의 +- 봇 user-agent를 통계에서 제외하거나 별도 컬럼으로 분리 +- DB write 실패가 전체 요청 실패로 번지지 않도록 방어 + +## 테스트 계획 + +- `.php` 요청은 404 또는 설정 status를 반환한다. +- `.php` 요청 시 `ConnectionMethodStats`와 `ConnectionHardwareStats`가 증가하지 않는다. +- 정상 URL은 기존 응답을 유지한다. +- `BLOCK_SUSPICIOUS_PATHS=False`일 때 차단이 비활성화된다. diff --git a/docs/error/ConnectionHardwareStats.MultipleObjectsReturned.md b/docs/error/ConnectionHardwareStats.MultipleObjectsReturned.md new file mode 100644 index 0000000..c43d308 --- /dev/null +++ b/docs/error/ConnectionHardwareStats.MultipleObjectsReturned.md @@ -0,0 +1,139 @@ +# ConnectionHardwareStats.MultipleObjectsReturned: get() returned more than one ConnectionHardwareStats -- it returned 2! + +**Issue ID:** 7563908033 +**Short ID:** PYTHON-DJANGO-7H +**Project:** python-django +**Date:** Aug 5, 2026 11:32:04 PM UTC + +## Tags + +- **environment:** production +- **handled:** no +- **interface_type:** exception +- **level:** error +- **mechanism:** django +- **runtime:** CPython 3.12.4 +- **runtime.name:** CPython +- **server_name:** 41ebdf1bd311 +- **transaction:** /wp.php +- **url:** http://devspoon.com/wp.php +- **user:** ip:20.100.178.111 + +## Exception + +### Exception 1 +**Type:** ConnectionHardwareStats.MultipleObjectsReturned +**Handled:** No +**Value:** get() returned more than one ConnectionHardwareStats -- it returned 2! + +#### Stacktrace + +``` + get in django/db/models/query.py [Line 640] (Not in app) + return clone._result_cache[0] + if not num: + raise self.model.DoesNotExist( + "%s matching query does not exist." % self.model._meta.object_name + ) + raise self.model.MultipleObjectsReturned( <-- SUSPECT LINE + "get() returned more than one %s -- it returned %s!" + % ( + self.model._meta.object_name, + num if not limit or num < limit else "more than %s" % (limit - 1), + ) +--- +Variable values: +{ + "args": [], + "clone": ", ]>", + "kwargs": { + "created_at__date": "datetime.date(2026, 8, 5)" + }, + "limit": "21", + "num": "2", + "self": "" +} + +======= + get_or_create in django/db/models/query.py [Line 916] (Not in app) + """ + # The get() needs to be targeted at the write database in order + # to avoid potential transaction consistency problems. + self._for_write = True + try: + return self.get(**kwargs), False <-- SUSPECT LINE + except self.model.DoesNotExist: + params = self._extract_model_params(defaults, **kwargs) + # Try to create an object using passed params. + try: + with transaction.atomic(using=self.db): +--- +Variable values: +{ + "defaults": "None", + "kwargs": { + "created_at__date": "datetime.date(2026, 8, 5)" + }, + "self": "" +} + +======= + __call__ in custom_middlewares/middlewares/statistics.py [Line 64] (In app) + today = timezone.now().date() + # 오늘 날짜의 통계 가져오기 + ( + stats, + created, + ) = ConnectionHardwareStats.objects.select_for_update().get_or_create( <-- SUSPECT LINE + created_at__date=today + ) + # 사용자 에이전트에 따라 카운트 업데이트 + if request.user_agent.is_mobile: + stats.mobile = F("mobile") + 1 +--- +Variable values: +{ + "request": "", + "self": "", + "today": "datetime.date(2026, 8, 5)" +} + +======= + inner in django/core/handlers/exception.py [Line 55] (Not in app) + else: + + @wraps(get_response) + def inner(request): + try: + response = get_response(request) <-- SUSPECT LINE + except Exception as exc: + response = response_for_exception(request, exc) + return response + + return inner +--- +Variable values: +{ + "exc": "MultipleObjectsReturned('get() returned more than one ConnectionHardwareStats -- it returned 2!')", + "get_response": "", + "request": "" +} + +======= +``` + +## Breadcrumbs + +- **default** `query` [info] + SELECT connection_hardware_stats.id, connection_hardware_stats.mobile, + connection_hardware_stats.tablet, connection_hardware_stats.pc, connection_hardware_stats.bot, + connection_hardware_stats.created_at + FROM connection_hardware_stats + WHERE (connection_hardware_stats.created_at AT TIME ZONE %s)::date = %s + LIMIT 21 + FOR + UPDATE + +## Request + +GET http://devspoon.com/wp.php diff --git a/docs/error/ConnectionMethodStats.MultipleObjectsReturned.md b/docs/error/ConnectionMethodStats.MultipleObjectsReturned.md new file mode 100644 index 0000000..613a319 --- /dev/null +++ b/docs/error/ConnectionMethodStats.MultipleObjectsReturned.md @@ -0,0 +1,163 @@ +# ConnectionMethodStats.MultipleObjectsReturned: get() returned more than one ConnectionMethodStats -- it returned 2! + +**Issue ID:** 7563408362 +**Short ID:** PYTHON-DJANGO-7G +**Project:** python-django +**Date:** Aug 5, 2026 11:37:09 PM UTC + +## Tags + +- **browser:** ClaudeBot 1.0 +- **browser.name:** ClaudeBot +- **device:** Desktop +- **device.family:** Spider +- **environment:** production +- **handled:** no +- **interface_type:** exception +- **level:** error +- **mechanism:** django +- **runtime:** CPython 3.12.4 +- **runtime.name:** CPython +- **server_name:** 41ebdf1bd311 +- **transaction:** /bbs/board.php +- **url:** http://devspoon.com/bbs/board.php +- **user:** ip:216.73.216.125 + +## Exception + +### Exception 1 +**Type:** ConnectionMethodStats.MultipleObjectsReturned +**Handled:** No +**Value:** get() returned more than one ConnectionMethodStats -- it returned 2! + +#### Stacktrace + +``` + get in django/db/models/query.py [Line 640] (Not in app) + return clone._result_cache[0] + if not num: + raise self.model.DoesNotExist( + "%s matching query does not exist." % self.model._meta.object_name + ) + raise self.model.MultipleObjectsReturned( <-- SUSPECT LINE + "get() returned more than one %s -- it returned %s!" + % ( + self.model._meta.object_name, + num if not limit or num < limit else "more than %s" % (limit - 1), + ) +--- +Variable values: +{ + "args": [], + "clone": ", ]>", + "kwargs": { + "created_at__date": "datetime.date(2026, 8, 5)" + }, + "limit": "21", + "num": "2", + "self": "" +} + +======= + get_or_create in django/db/models/query.py [Line 916] (Not in app) + """ + # The get() needs to be targeted at the write database in order + # to avoid potential transaction consistency problems. + self._for_write = True + try: + return self.get(**kwargs), False <-- SUSPECT LINE + except self.model.DoesNotExist: + params = self._extract_model_params(defaults, **kwargs) + # Try to create an object using passed params. + try: + with transaction.atomic(using=self.db): +--- +Variable values: +{ + "defaults": "None", + "kwargs": { + "created_at__date": "datetime.date(2026, 8, 5)" + }, + "self": "" +} + +======= + stats in custom_middlewares/middlewares/statistics.py [Line 24] (In app) + with transaction.atomic(): + # 오늘 날짜의 통계 가져오기 (잠금) + ( + stats, + created, + ) = ConnectionMethodStats.objects.select_for_update().get_or_create( <-- SUSPECT LINE + created_at__date=today + ) + + # 운영체제에 따라 카운트 업데이트 + if "Windows" in os_info: +--- +Variable values: +{ + "os_info": "'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +claudebot@anthropic.com)'", + "self": "", + "today": "datetime.date(2026, 8, 5)" +} + +======= + __call__ in custom_middlewares/middlewares/statistics.py [Line 45] (In app) + stats.save() # 변경 사항 저장 + + def __call__(self, request): + if "HTTP_USER_AGENT" in request.META: + if "admin" not in request.path: + self.stats(request.META["HTTP_USER_AGENT"]) <-- SUSPECT LINE + + response = self.get_response(request) + + return response + +--- +Variable values: +{ + "request": "", + "self": "" +} + +======= + inner in django/core/handlers/exception.py [Line 55] (Not in app) + else: + + @wraps(get_response) + def inner(request): + try: + response = get_response(request) <-- SUSPECT LINE + except Exception as exc: + response = response_for_exception(request, exc) + return response + + return inner +--- +Variable values: +{ + "exc": "MultipleObjectsReturned('get() returned more than one ConnectionMethodStats -- it returned 2!')", + "get_response": "", + "request": "" +} + +======= +``` + +## Breadcrumbs + +- **default** `query` [info] + SELECT connection_method_stats.id, connection_method_stats.win, connection_method_stats.mac, + connection_method_stats.iph, connection_method_stats.android, connection_method_stats.oth, + connection_method_stats.created_at + FROM connection_method_stats + WHERE (connection_method_stats.created_at AT TIME ZONE %s)::date = %s + LIMIT 21 + FOR + UPDATE + +## Request + +GET http://devspoon.com/bbs/board.php diff --git a/docs/error/DataError.md b/docs/error/DataError.md new file mode 100644 index 0000000..561d71c --- /dev/null +++ b/docs/error/DataError.md @@ -0,0 +1,657 @@ +# DataError: value too long for type character varying(16) + +**Issue ID:** 7290693067 +**Short ID:** PYTHON-DJANGO-7B +**Project:** python-django +**Date:** Jul 12, 2026 11:28:03 AM UTC + +## Tags + +- **browser:** Chrome 142.0.0 +- **browser.name:** Chrome +- **client_os:** Linux +- **client_os.name:** Linux +- **device:** Mac +- **device.family:** Mac +- **environment:** production +- **handled:** no +- **interface_type:** exception +- **level:** error +- **mechanism:** django +- **runtime:** CPython 3.12.4 +- **runtime.name:** CPython +- **server_name:** 41ebdf1bd311 +- **transaction:** /portfolio/mail +- **url:** http://devspoon.com/portfolio/mail +- **user:** ip:185.220.101.168 + +## Exceptions + +### Exception 1 +**Type:** StringDataRightTruncation +**Handled:** No +**Value:** value too long for type character varying(16) + + +#### Stacktrace + +``` + _execute in django/db/backends/utils.py [Line 89] (Not in app) + with self.db.wrap_database_errors: + if params is None: + # params default might be backend specific. + return self.cursor.execute(sql) + else: + return self.cursor.execute(sql, params) <-- SUSPECT LINE + + def _executemany(self, sql, param_list, *ignored_wrapper_args): + self.db.validate_no_broken_transaction() + with self.db.wrap_database_errors: + return self.cursor.executemany(sql, param_list) +--- +Variable values: +{ + "ignored_wrapper_args": [ + "False", + { + "connection": "", + "cursor": "" + } + ], + "params": [ + "PortfolioMixin.Languages.KOREAN", + "datetime.datetime(2026, 7, 12, 11, 28, 2, 963947, tzinfo=datetime.timezone.utc)", + "'WQpVFdsvoZQIReIZWeFxEX'", + "True", + "'do.h.upodu.la086@gmail.com'", + "'oYTzorlWrJPiybaSdcrXQ'", + "'nmOKenyYAhPTQoqhys'", + "'dNawChXyPDhiNhVzcg'" + ], + "self": "", + "sql": "'INSERT INTO \"get_in_touch\" (\"language\", \"created_at\", \"name\", \"state\", \"email\", \"phone_number\", \"subject\", \"message\") VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING \"get_in_touch\".\"id\"'" +} + +======= +``` +------ +### Exception 2 +**Type:** DataError +**Handled:** No +**Value:** value too long for type character varying(16) + + +#### Stacktrace + +``` + _execute in django/db/backends/utils.py [Line 89] (Not in app) + with self.db.wrap_database_errors: + if params is None: + # params default might be backend specific. + return self.cursor.execute(sql) + else: + return self.cursor.execute(sql, params) <-- SUSPECT LINE + + def _executemany(self, sql, param_list, *ignored_wrapper_args): + self.db.validate_no_broken_transaction() + with self.db.wrap_database_errors: + return self.cursor.executemany(sql, param_list) +--- +Variable values: +{ + "ignored_wrapper_args": [ + "False", + { + "connection": "", + "cursor": "" + } + ], + "params": [ + "PortfolioMixin.Languages.KOREAN", + "datetime.datetime(2026, 7, 12, 11, 28, 2, 963947, tzinfo=datetime.timezone.utc)", + "'WQpVFdsvoZQIReIZWeFxEX'", + "True", + "'do.h.upodu.la086@gmail.com'", + "'oYTzorlWrJPiybaSdcrXQ'", + "'nmOKenyYAhPTQoqhys'", + "'dNawChXyPDhiNhVzcg'" + ], + "self": "", + "sql": "'INSERT INTO \"get_in_touch\" (\"language\", \"created_at\", \"name\", \"state\", \"email\", \"phone_number\", \"subject\", \"message\") VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING \"get_in_touch\".\"id\"'" +} + +======= + __exit__ in django/db/utils.py [Line 91] (Not in app) + dj_exc_value = dj_exc_type(*exc_value.args) + # Only set the 'errors_occurred' flag for errors that may make + # the connection unusable. + if dj_exc_type not in (DataError, IntegrityError): + self.wrapper.errors_occurred = True + raise dj_exc_value.with_traceback(traceback) from exc_value <-- SUSPECT LINE + + def __call__(self, func): + # Note that we are intentionally not using @wraps here for performance + # reasons. Refs #21109. + def inner(*args, **kwargs): +--- +Variable values: +{ + "db_exc_type": "", + "dj_exc_type": "", + "dj_exc_value": "DataError('value too long for type character varying(16)\\n')", + "exc_type": "", + "exc_value": "StringDataRightTruncation('value too long for type character varying(16)\\n')", + "self": "", + "traceback": "" +} + +======= + _execute in django/db/backends/utils.py [Line 84] (Not in app) + executor = functools.partial(wrapper, executor) + return executor(sql, params, many, context) + + def _execute(self, sql, params, *ignored_wrapper_args): + self.db.validate_no_broken_transaction() + with self.db.wrap_database_errors: <-- SUSPECT LINE + if params is None: + # params default might be backend specific. + return self.cursor.execute(sql) + else: + return self.cursor.execute(sql, params) +--- +Variable values: +{ + "ignored_wrapper_args": [ + "False", + { + "connection": "", + "cursor": "" + } + ], + "params": [ + "PortfolioMixin.Languages.KOREAN", + "datetime.datetime(2026, 7, 12, 11, 28, 2, 963947, tzinfo=datetime.timezone.utc)", + "'WQpVFdsvoZQIReIZWeFxEX'", + "True", + "'do.h.upodu.la086@gmail.com'", + "'oYTzorlWrJPiybaSdcrXQ'", + "'nmOKenyYAhPTQoqhys'", + "'dNawChXyPDhiNhVzcg'" + ], + "self": "", + "sql": "'INSERT INTO \"get_in_touch\" (\"language\", \"created_at\", \"name\", \"state\", \"email\", \"phone_number\", \"subject\", \"message\") VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING \"get_in_touch\".\"id\"'" +} + +======= + _execute_with_wrappers in django/db/backends/utils.py [Line 80] (Not in app) + + def _execute_with_wrappers(self, sql, params, many, executor): + context = {"connection": self.db, "cursor": self} + for wrapper in reversed(self.db.execute_wrappers): + executor = functools.partial(wrapper, executor) + return executor(sql, params, many, context) <-- SUSPECT LINE + + def _execute(self, sql, params, *ignored_wrapper_args): + self.db.validate_no_broken_transaction() + with self.db.wrap_database_errors: + if params is None: +--- +Variable values: +{ + "context": { + "connection": "", + "cursor": "" + }, + "executor": ">", + "many": "False", + "params": [ + "PortfolioMixin.Languages.KOREAN", + "datetime.datetime(2026, 7, 12, 11, 28, 2, 963947, tzinfo=datetime.timezone.utc)", + "'WQpVFdsvoZQIReIZWeFxEX'", + "True", + "'do.h.upodu.la086@gmail.com'", + "'oYTzorlWrJPiybaSdcrXQ'", + "'nmOKenyYAhPTQoqhys'", + "'dNawChXyPDhiNhVzcg'" + ], + "self": "", + "sql": "'INSERT INTO \"get_in_touch\" (\"language\", \"created_at\", \"name\", \"state\", \"email\", \"phone_number\", \"subject\", \"message\") VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING \"get_in_touch\".\"id\"'" +} + +======= + execute in django/db/backends/utils.py [Line 67] (Not in app) + else: + params = params or () + return self.cursor.callproc(procname, params, kparams) + + def execute(self, sql, params=None): + return self._execute_with_wrappers( <-- SUSPECT LINE + sql, params, many=False, executor=self._execute + ) + + def executemany(self, sql, param_list): + return self._execute_with_wrappers( +--- +Variable values: +{ + "params": [ + "PortfolioMixin.Languages.KOREAN", + "datetime.datetime(2026, 7, 12, 11, 28, 2, 963947, tzinfo=datetime.timezone.utc)", + "'WQpVFdsvoZQIReIZWeFxEX'", + "True", + "'do.h.upodu.la086@gmail.com'", + "'oYTzorlWrJPiybaSdcrXQ'", + "'nmOKenyYAhPTQoqhys'", + "'dNawChXyPDhiNhVzcg'" + ], + "self": "", + "sql": "'INSERT INTO \"get_in_touch\" (\"language\", \"created_at\", \"name\", \"state\", \"email\", \"phone_number\", \"subject\", \"message\") VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING \"get_in_touch\".\"id\"'" +} + +======= + execute_sql in django/db/models/sql/compiler.py [Line 1822] (Not in app) + ) + opts = self.query.get_meta() + self.returning_fields = returning_fields + with self.connection.cursor() as cursor: + for sql, params in self.as_sql(): + cursor.execute(sql, params) <-- SUSPECT LINE + if not self.returning_fields: + return [] + if ( + self.connection.features.can_return_rows_from_bulk_insert + and len(self.query.objs) > 1 +--- +Variable values: +{ + "cursor": "", + "opts": "", + "params": [ + "PortfolioMixin.Languages.KOREAN", + "datetime.datetime(2026, 7, 12, 11, 28, 2, 963947, tzinfo=datetime.timezone.utc)", + "'WQpVFdsvoZQIReIZWeFxEX'", + "True", + "'do.h.upodu.la086@gmail.com'", + "'oYTzorlWrJPiybaSdcrXQ'", + "'nmOKenyYAhPTQoqhys'", + "'dNawChXyPDhiNhVzcg'" + ], + "returning_fields": [ + "" + ], + "self": " using='default'>", + "sql": "'INSERT INTO \"get_in_touch\" (\"language\", \"created_at\", \"name\", \"state\", \"email\", \"phone_number\", \"subject\", \"message\") VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING \"get_in_touch\".\"id\"'" +} + +======= + _insert in django/db/models/query.py [Line 1805] (Not in app) + on_conflict=on_conflict, + update_fields=update_fields, + unique_fields=unique_fields, + ) + query.insert_values(fields, objs, raw=raw) + return query.get_compiler(using=using).execute_sql(returning_fields) <-- SUSPECT LINE + + _insert.alters_data = True + _insert.queryset_only = False + + def _batched_insert( +--- +Variable values: +{ + "fields": [ + "", + "", + "", + "", + "", + "", + "", + "" + ], + "objs": [ + "" + ], + "on_conflict": "None", + "query": "", + "raw": "False", + "returning_fields": [ + "" + ], + "self": "", + "unique_fields": "None", + "update_fields": "None", + "using": "'default'" +} + +======= + manager_method in django/db/models/manager.py [Line 87] (Not in app) + @classmethod + def _get_queryset_methods(cls, queryset_class): + def create_method(name, method): + @wraps(method) + def manager_method(self, *args, **kwargs): + return getattr(self.get_queryset(), name)(*args, **kwargs) <-- SUSPECT LINE + + return manager_method + + new_methods = {} + for name, method in inspect.getmembers( +--- +Variable values: +{ + "args": [ + [ + "" + ] + ], + "kwargs": { + "fields": [ + "", + "", + "", + "", + "", + "", + "", + "" + ], + "raw": "False", + "returning_fields": [ + "" + ], + "using": "'default'" + }, + "name": "'_insert'", + "self": "" +} + +======= + _do_insert in django/db/models/base.py [Line 1061] (Not in app) + def _do_insert(self, manager, using, fields, returning_fields, raw): + """ + Do an INSERT. If returning_fields is defined then this method should + return the newly created data for the model. + """ + return manager._insert( <-- SUSPECT LINE + [self], + fields=fields, + returning_fields=returning_fields, + using=using, + raw=raw, +--- +Variable values: +{ + "fields": [ + "", + "", + "", + "", + "", + "", + "", + "" + ], + "manager": "", + "raw": "False", + "returning_fields": [ + "" + ], + "self": "", + "using": "'default'" +} + +======= + _save_table in django/db/models/base.py [Line 1020] (Not in app) + fields = meta.local_concrete_fields + if not pk_set: + fields = [f for f in fields if f is not meta.auto_field] + + returning_fields = meta.db_returning_fields + results = self._do_insert( <-- SUSPECT LINE + cls._base_manager, using, fields, returning_fields, raw + ) + if results: + for value, field in zip(results[0], returning_fields): + setattr(self, field.attname, value) +--- +Variable values: +{ + "cls": "", + "force_insert": "True", + "force_update": "False", + "meta": "", + "non_pks": [ + "", + "", + "", + "", + "", + "", + "", + "" + ], + "pk_val": "None", + "raw": "False", + "self": "", + "update_fields": "None", + "using": "'default'" +} + +======= + save_base in django/db/models/base.py [Line 877] (Not in app) + context_manager = transaction.mark_for_rollback_on_error(using=using) + with context_manager: + parent_inserted = False + if not raw: + parent_inserted = self._save_parents(cls, using, update_fields) + updated = self._save_table( <-- SUSPECT LINE + raw, + cls, + force_insert or parent_inserted, + force_update, + using, +--- +Variable values: +{ + "cls": "", + "context_manager": "", + "force_insert": "True", + "force_update": "False", + "meta": "", + "origin": "", + "raw": "False", + "self": "", + "update_fields": "None", + "using": "'default'" +} + +======= + save in django/db/models/base.py [Line 814] (Not in app) + field_names.add(field.attname) + loaded_fields = field_names.difference(deferred_fields) + if loaded_fields: + update_fields = frozenset(loaded_fields) + + self.save_base( <-- SUSPECT LINE + using=using, + force_insert=force_insert, + force_update=force_update, + update_fields=update_fields, + ) +--- +Variable values: +{ + "deferred_fields": [], + "force_insert": "True", + "force_update": "False", + "self": "", + "update_fields": "None", + "using": "'default'" +} + +======= + create in django/db/models/query.py [Line 658] (Not in app) + Create a new object with the given kwargs, saving it to the database + and returning the created object. + """ + obj = self.model(**kwargs) + self._for_write = True + obj.save(force_insert=True, using=self.db) <-- SUSPECT LINE + return obj + + async def acreate(self, **kwargs): + return await sync_to_async(self.create)(**kwargs) + +--- +Variable values: +{ + "kwargs": { + "email": "'do.h.upodu.la086@gmail.com'", + "message": "'dNawChXyPDhiNhVzcg'", + "name": "'WQpVFdsvoZQIReIZWeFxEX'", + "phone_number": "'oYTzorlWrJPiybaSdcrXQ'", + "state": "True", + "subject": "'nmOKenyYAhPTQoqhys'" + }, + "obj": "", + "self": "" +} + +======= + manager_method in django/db/models/manager.py [Line 87] (Not in app) + @classmethod + def _get_queryset_methods(cls, queryset_class): + def create_method(name, method): + @wraps(method) + def manager_method(self, *args, **kwargs): + return getattr(self.get_queryset(), name)(*args, **kwargs) <-- SUSPECT LINE + + return manager_method + + new_methods = {} + for name, method in inspect.getmembers( +--- +Variable values: +{ + "args": [], + "kwargs": { + "email": "'do.h.upodu.la086@gmail.com'", + "message": "'dNawChXyPDhiNhVzcg'", + "name": "'WQpVFdsvoZQIReIZWeFxEX'", + "phone_number": "'oYTzorlWrJPiybaSdcrXQ'", + "state": "True", + "subject": "'nmOKenyYAhPTQoqhys'" + }, + "name": "'create'", + "self": "" +} + +======= + post in portfolio/views.py [Line 278] (In app) + recipient_list=[settings.DEFAULT_FROM_EMAIL], + html_message=msg_html, + fail_silently=False, + ) + + GetInTouchLog.objects.create( <-- SUSPECT LINE + name=name, + state=True, + email=emailfrom, + phone_number=number, + subject=subject, +--- +Variable values: +{ + "args": [], + "emailfrom": "'do.h.upodu.la086@gmail.com'", + "emailto": "'test@admin.com'", + "kwargs": {}, + "name": "'WQpVFdsvoZQIReIZWeFxEX'", + "number": "'oYTzorlWrJPiybaSdcrXQ'", + "pattern": "re.compile('^[a-zA-Z0-9+-_.]+@[a-zA-Z0-9-]+\\\\.[a-zA-Z0-9-.]+$')", + "request": "", + "self": "", + "subject": "'nmOKenyYAhPTQoqhys'" +} + +======= + dispatch in django/views/generic/base.py [Line 143] (Not in app) + handler = getattr( + self, request.method.lower(), self.http_method_not_allowed + ) + else: + handler = self.http_method_not_allowed + return handler(request, *args, **kwargs) <-- SUSPECT LINE + + def http_method_not_allowed(self, request, *args, **kwargs): + logger.warning( + "Method Not Allowed (%s): %s", + request.method, +--- +Variable values: +{ + "args": [], + "handler": ">", + "kwargs": {}, + "request": "", + "self": "" +} + +======= +``` + +## Breadcrumbs + +- **default** `query` [info] + SELECT connection_method_stats.id, connection_method_stats.win, connection_method_stats.mac, + connection_method_stats.iph, connection_method_stats.android, connection_method_stats.oth, + connection_method_stats.created_at + FROM connection_method_stats + WHERE (connection_method_stats.created_at AT TIME ZONE %s)::date = %s + LIMIT 21 + FOR + UPDATE +- **default** `query` [info] + UPDATE connection_method_stats + SET win = %s, mac = %s, iph = %s, android = %s, + oth = (connection_method_stats.oth + %s), created_at = %s + WHERE connection_method_stats.id = %s +- **default** `query` [info] + SELECT connection_hardware_stats.id, connection_hardware_stats.mobile, + connection_hardware_stats.tablet, connection_hardware_stats.pc, connection_hardware_stats.bot, + connection_hardware_stats.created_at + FROM connection_hardware_stats + WHERE (connection_hardware_stats.created_at AT TIME ZONE %s)::date = %s + LIMIT 21 + FOR + UPDATE +- **redis** `redis` [info] + GET 'devspoon:1:django_user_agents.772887ef77de1564695a82eb61cd6ed4' + {"db.operation":"GET","redis.command":"GET","redis.key":"devspoon:1:django_user_agents.772887ef77de1564695a82eb61cd6ed4"} +- **default** `query` [info] + UPDATE connection_hardware_stats + SET mobile = %s, tablet = %s, pc = (connection_hardware_stats.pc + %s), bot = %s, + created_at = %s + WHERE connection_hardware_stats.id = %s +- **default** `query` [info] + INSERT INTO get_in_touch (language, created_at, name, state, email, phone_number, + subject, message) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING get_in_touch.id + +## Request + +POST http://devspoon.com/portfolio/mail + +Body: +``` +{ + "csrfmiddlewaretoken": "[Filtered]", + "emailfrom": "do.h.upodu.la086@gmail.com", + "emailto": "test@admin.com", + "message": "dNawChXyPDhiNhVzcg", + "name": "WQpVFdsvoZQIReIZWeFxEX", + "number": "oYTzorlWrJPiybaSdcrXQ", + "subject": "nmOKenyYAhPTQoqhys" +} +``` diff --git a/docs/error/Error sending email to ['[email]']: HTTP Error 401: Unauthorized.md b/docs/error/Error sending email to ['[email]']: HTTP Error 401: Unauthorized.md new file mode 100644 index 0000000..45cbc68 --- /dev/null +++ b/docs/error/Error sending email to ['[email]']: HTTP Error 401: Unauthorized.md @@ -0,0 +1,31 @@ +# Error sending email to ['[email]']: HTTP Error 401: Unauthorized + +**Issue ID:** 7290693077 +**Short ID:** PYTHON-DJANGO-7C +**Project:** python-django +**Date:** Jul 12, 2026 11:28:05 AM UTC + +## Tags + +- **environment:** production +- **interface_type:** contexts +- **level:** error +- **logger:** utils.email.async_send_email +- **runtime:** CPython 3.12.4 +- **runtime.name:** CPython +- **server_name:** 41ebdf1bd311 + +## Breadcrumbs + +- **log** `gunicorn.access` [info] + 172.18.0.6 - - [12/Jul/2026:09:22:59 +0000] "GET /site/phpinfo.php HTTP/1.0" 200 31507 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +- **log** `gunicorn.access` [info] + 172.18.0.6 - - [12/Jul/2026:09:22:59 +0000] "GET /wp-admin/phpinfo.php HTTP/1.0" 200 31507 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +- **log** `gunicorn.access` [info] + 172.18.0.6 - - [12/Jul/2026:09:23:00 +0000] "GET /includes/phpinfo.php HTTP/1.0" 200 31507 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +- **log** `gunicorn.access` [info] + 172.18.0.6 - - [12/Jul/2026:10:30:24 +0000] "GET /search/queryset/?tag=e-commerce HTTP/1.0" 200 35744 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1" +- **log** `gunicorn.access` [info] + 172.18.0.6 - - [12/Jul/2026:10:51:29 +0000] "GET /blog/opensource/detail/4/5 HTTP/1.0" 200 31507 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1" +- **log** `gunicorn.access` [info] + 172.18.0.6 - - [12/Jul/2026:11:02:42 +0000] "GET /blog/opensource/detail/4/3 HTTP/1.0" 200 31507 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1" diff --git "a/docs/error/Failed to send email, error: HTTP Error 401: Unauthorized, response body: b'{\"errors\":[{\"message\":\"Maximum credits exceeded\",\"field\":null,\"help\":null}]}'.md" "b/docs/error/Failed to send email, error: HTTP Error 401: Unauthorized, response body: b'{\"errors\":[{\"message\":\"Maximum credits exceeded\",\"field\":null,\"help\":null}]}'.md" new file mode 100644 index 0000000..3b0d66f --- /dev/null +++ "b/docs/error/Failed to send email, error: HTTP Error 401: Unauthorized, response body: b'{\"errors\":[{\"message\":\"Maximum credits exceeded\",\"field\":null,\"help\":null}]}'.md" @@ -0,0 +1,33 @@ +# Failed to send email, error: HTTP Error 401: Unauthorized, response body: b'{"errors":[{"message":"Maximum credits exceeded","field":null,"help":null}]}' + +**Issue ID:** 7290693081 +**Short ID:** PYTHON-DJANGO-7D +**Project:** python-django +**Date:** Jul 12, 2026 11:28:05 AM UTC + +## Tags + +- **environment:** production +- **interface_type:** contexts +- **level:** error +- **logger:** sendgrid_backend.mail +- **runtime:** CPython 3.12.4 +- **runtime.name:** CPython +- **server_name:** 41ebdf1bd311 + +## Breadcrumbs + +- **log** `gunicorn.access` [info] + 172.18.0.6 - - [12/Jul/2026:09:22:58 +0000] "GET /public_html/phpinfo.php HTTP/1.0" 200 31507 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +- **log** `gunicorn.access` [info] + 172.18.0.6 - - [12/Jul/2026:09:22:59 +0000] "GET /site/phpinfo.php HTTP/1.0" 200 31507 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +- **log** `gunicorn.access` [info] + 172.18.0.6 - - [12/Jul/2026:09:22:59 +0000] "GET /wp-admin/phpinfo.php HTTP/1.0" 200 31507 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +- **log** `gunicorn.access` [info] + 172.18.0.6 - - [12/Jul/2026:09:23:00 +0000] "GET /includes/phpinfo.php HTTP/1.0" 200 31507 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +- **log** `gunicorn.access` [info] + 172.18.0.6 - - [12/Jul/2026:10:30:24 +0000] "GET /search/queryset/?tag=e-commerce HTTP/1.0" 200 35744 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1" +- **log** `gunicorn.access` [info] + 172.18.0.6 - - [12/Jul/2026:10:51:29 +0000] "GET /blog/opensource/detail/4/5 HTTP/1.0" 200 31507 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1" +- **log** `gunicorn.access` [info] + 172.18.0.6 - - [12/Jul/2026:11:02:42 +0000] "GET /blog/opensource/detail/4/3 HTTP/1.0" 200 31507 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1" diff --git a/doc/helpers_code/__init__.py b/docs/helpers_code/__init__.py similarity index 100% rename from doc/helpers_code/__init__.py rename to docs/helpers_code/__init__.py diff --git a/doc/helpers_code/models/base_models.py b/docs/helpers_code/models/base_models.py similarity index 100% rename from doc/helpers_code/models/base_models.py rename to docs/helpers_code/models/base_models.py diff --git a/doc/helpers_code/translate_sample.html b/docs/helpers_code/translate_sample.html similarity index 100% rename from doc/helpers_code/translate_sample.html rename to docs/helpers_code/translate_sample.html diff --git a/doc/helpers_code/urls_ex.py b/docs/helpers_code/urls_ex.py similarity index 100% rename from doc/helpers_code/urls_ex.py rename to docs/helpers_code/urls_ex.py diff --git a/doc/ipynb_files/email-validator.ipynb b/docs/ipynb_files/email-validator.ipynb similarity index 100% rename from doc/ipynb_files/email-validator.ipynb rename to docs/ipynb_files/email-validator.ipynb diff --git a/doc/ipynb_files/orm_base.ipynb b/docs/ipynb_files/orm_base.ipynb similarity index 100% rename from doc/ipynb_files/orm_base.ipynb rename to docs/ipynb_files/orm_base.ipynb diff --git a/doc/ipynb_files/orm_check_interval_3day_users.ipynb b/docs/ipynb_files/orm_check_interval_3day_users.ipynb similarity index 100% rename from doc/ipynb_files/orm_check_interval_3day_users.ipynb rename to docs/ipynb_files/orm_check_interval_3day_users.ipynb diff --git a/doc/note_commands.md b/docs/note_commands.md similarity index 100% rename from doc/note_commands.md rename to docs/note_commands.md diff --git a/doc/reference.md b/docs/reference.md similarity index 100% rename from doc/reference.md rename to docs/reference.md From bff73165df8b80ac9ef494e83f9a3e579801c27c Mon Sep 17 00:00:00 2001 From: bluebamus Date: Mon, 10 Aug 2026 04:04:33 +0000 Subject: [PATCH 02/12] fix(errors): return real HTTP status codes from custom error pages The 400/403/404/500 handlers built an HttpResponse, set status_code on it, then discarded it and returned render()'s new response, which defaults to 200. Every error page answered HTTP 200. Scanners saw every path as valid, and search engines and monitoring could not tell an error from a normal page. The existing test only checked for the "404 Error" string in the body, so it did not catch it. Co-Authored-By: Claude Opus 5 (1M context) --- common/error/error_views.py | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/common/error/error_views.py b/common/error/error_views.py index 8c13a9d..d04f711 100644 --- a/common/error/error_views.py +++ b/common/error/error_views.py @@ -1,6 +1,5 @@ import logging from django.conf import settings -from django.http import HttpResponse from django.shortcuts import render, redirect logger = logging.getLogger(getattr(settings, "BLOG_LOGGER", "django")) @@ -9,37 +8,37 @@ # 400(Error) def bad_request_page(request, exception=None): logger.debug("http 400 error") - response = HttpResponse() - response.status_code = 400 # Or any other HTTP status code - context = {"status_code": response.status_code} - return render(request, "errors/error.html", context=context) + # render()는 별도 응답을 새로 만든다. 위에서 status_code를 세팅한 응답은 + # 버려지므로 status를 render()에 직접 넘겨야 실제 400가 나간다. + context = {"status_code": 400} + return render(request, "errors/error.html", context=context, status=400) # 403(Error) def permission_denied_page(request, exception=None): logger.debug("http 403 error") - response = HttpResponse() - response.status_code = 403 # Or any other HTTP status code - context = {"status_code": response.status_code} - return render(request, "errors/error.html", context=context) + # render()는 별도 응답을 새로 만든다. 위에서 status_code를 세팅한 응답은 + # 버려지므로 status를 render()에 직접 넘겨야 실제 403가 나간다. + context = {"status_code": 403} + return render(request, "errors/error.html", context=context, status=403) # 404(Error) def page_not_found_page(request, exception=None): logger.debug("http 404 error") - response = HttpResponse() - response.status_code = 404 # Or any other HTTP status code - context = {"status_code": response.status_code} - return render(request, "errors/error.html", context=context) + # render()는 별도 응답을 새로 만든다. 위에서 status_code를 세팅한 응답은 + # 버려지므로 status를 render()에 직접 넘겨야 실제 404가 나간다. + context = {"status_code": 404} + return render(request, "errors/error.html", context=context, status=404) # 500(Error) def server_error_page(request, exception=None): logger.debug("http 500 error") - response = HttpResponse() - response.status_code = 500 # Or any other HTTP status code - context = {"status_code": response.status_code} - return render(request, "errors/error.html", context=context) + # render()는 별도 응답을 새로 만든다. 위에서 status_code를 세팅한 응답은 + # 버려지므로 status를 render()에 직접 넘겨야 실제 500가 나간다. + context = {"status_code": 500} + return render(request, "errors/error.html", context=context, status=500) # CSRF(Error) From 26226b87f49e1b4bd24c1f6788926fdac6c4bdc0 Mon Sep 17 00:00:00 2001 From: bluebamus Date: Mon, 10 Aug 2026 04:04:45 +0000 Subject: [PATCH 03/12] fix(stats): key daily connection stats on a unique stat_date get_or_create(created_at__date=today) could not write the lookup value on insert, so the same day accumulated multiple rows. Once a duplicate existed, every later request raised MultipleObjectsReturned (Sentry PYTHON-DJANGO-7H, 7G). Add stat_date as the aggregation key with a unique constraint, and replace the lookup with UPDATE -> INSERT -> (on race) UPDATE. A single UPDATE statement is atomic, so select_for_update is no longer needed, and the unique constraint is what finally prevents duplicates. stat_date is nullable because migrations are gitignored here: adding a not-null unique column to a populated table would give every existing row the same default and violate the constraint. The new dedupe_connection_stats command merges duplicates by summing their counters and backfills stat_date afterwards. Also: - exclude paths by prefix instead of testing for the "admin" substring, which skipped legitimate URLs that merely contained it - keep statistics DB errors from failing the request - fix the admin changelist, which matched created_at__day (day of month) and so mixed in rows from other months Co-Authored-By: Claude Opus 5 (1M context) --- .../admin/home_statistics_admin.py | 24 ++- custom_middlewares/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../commands/dedupe_connection_stats.py | 92 ++++++++++ custom_middlewares/middlewares/statistics.py | 170 ++++++++++++------ custom_middlewares/models.py | 45 +++-- custom_middlewares/tests.py | 3 - custom_middlewares/tests/__init__.py | 0 .../tests/test_dedupe_command.py | 107 +++++++++++ custom_middlewares/tests/test_statistics.py | 160 +++++++++++++++++ pytest.ini | 2 + 11 files changed, 520 insertions(+), 83 deletions(-) create mode 100644 custom_middlewares/management/__init__.py create mode 100644 custom_middlewares/management/commands/__init__.py create mode 100644 custom_middlewares/management/commands/dedupe_connection_stats.py delete mode 100644 custom_middlewares/tests.py create mode 100644 custom_middlewares/tests/__init__.py create mode 100644 custom_middlewares/tests/test_dedupe_command.py create mode 100644 custom_middlewares/tests/test_statistics.py diff --git a/custom_middlewares/admin/home_statistics_admin.py b/custom_middlewares/admin/home_statistics_admin.py index b26b926..7f8b58f 100644 --- a/custom_middlewares/admin/home_statistics_admin.py +++ b/custom_middlewares/admin/home_statistics_admin.py @@ -16,13 +16,11 @@ class ConnectionMethodStatsAdmin(admin.ModelAdmin): ) def changelist_view(self, request, extra_context=None): - stat_data = ( - ConnectionMethodStats.objects.filter( - created_at__day=timezone.now().date().day - ) - # .annotate() - .values("win", "mac", "iph", "android", "oth") - ) + # created_at__day은 '일(day of month)'만 비교해 다른 달의 row까지 섞였다. + # 집계키인 stat_date로 오늘 row만 조회한다. + stat_data = ConnectionMethodStats.objects.filter( + stat_date=timezone.localdate() + ).values("win", "mac", "iph", "android", "oth") # data = newstats.objects.all() # newdata = serializers.serialize('json', list(data), fields=("win","mac","iph","android","oth")) @@ -42,13 +40,11 @@ class ConnectionHardwareStatsAdmin(admin.ModelAdmin): ) def changelist_view(self, request, extra_context=None): - stat_data = ( - ConnectionHardwareStats.objects.filter( - created_at__day=timezone.now().date().day - ) - # .annotate() - .values("mobile", "tablet", "pc", "bot") - ) + # created_at__day은 '일(day of month)'만 비교해 다른 달의 row까지 섞였다. + # 집계키인 stat_date로 오늘 row만 조회한다. + stat_data = ConnectionHardwareStats.objects.filter( + stat_date=timezone.localdate() + ).values("mobile", "tablet", "pc", "bot") # data = newstats.objects.all() # newdata = serializers.serialize('json', list(data), fields=("mobile","tablet","pc","bot")) diff --git a/custom_middlewares/management/__init__.py b/custom_middlewares/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_middlewares/management/commands/__init__.py b/custom_middlewares/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_middlewares/management/commands/dedupe_connection_stats.py b/custom_middlewares/management/commands/dedupe_connection_stats.py new file mode 100644 index 0000000..ee59baa --- /dev/null +++ b/custom_middlewares/management/commands/dedupe_connection_stats.py @@ -0,0 +1,92 @@ +from collections import defaultdict + +from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils import timezone + +from custom_middlewares.models import ConnectionHardwareStats, ConnectionMethodStats + +TARGET_MODELS = (ConnectionMethodStats, ConnectionHardwareStats) + + +class Command(BaseCommand): + help = ( + "일별 접속 통계 테이블의 중복 row를 날짜별로 합산해 1개로 정리하고 " + "stat_date를 backfill한다. 여러 번 실행해도 결과가 같다." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", + action="store_true", + help="변경 없이 정리 대상만 출력한다.", + ) + + def handle(self, *args, **options): + dry_run = options["dry_run"] + if dry_run: + self.stdout.write(self.style.WARNING("dry-run: 변경하지 않는다.")) + + for model in TARGET_MODELS: + self.process_model(model, dry_run) + + def process_model(self, model, dry_run): + label = model._meta.db_table + groups = defaultdict(list) + for row in model.objects.all().order_by("pk"): + groups[self.resolve_stat_date(row)].append(row) + + merged_dates = 0 + removed_rows = 0 + backfilled = 0 + + for stat_date, rows in sorted(groups.items()): + keeper = rows[0] + duplicates = rows[1:] + needs_backfill = keeper.stat_date != stat_date + + if not duplicates and not needs_backfill: + continue + + totals = { + field: sum(getattr(row, field) for row in rows) + for field in model.COUNTER_FIELDS + } + + if duplicates: + merged_dates += 1 + removed_rows += len(duplicates) + if needs_backfill: + backfilled += 1 + + self.stdout.write( + f"{label} {stat_date}: rows={len(rows)} -> 1, {totals}" + ) + + if dry_run: + continue + + with transaction.atomic(): + # 유니크 제약 충돌을 피하려고 중복 row를 먼저 지운다. + if duplicates: + model.objects.filter( + pk__in=[row.pk for row in duplicates] + ).delete() + + keeper.stat_date = stat_date + for field, value in totals.items(): + setattr(keeper, field, value) + keeper.save(update_fields=["stat_date", *model.COUNTER_FIELDS]) + + summary = ( + f"{label}: 합산한 날짜 {merged_dates}건, 삭제한 중복 row {removed_rows}건, " + f"stat_date backfill {backfilled}건" + ) + self.stdout.write(self.style.SUCCESS(summary)) + + @staticmethod + def resolve_stat_date(row): + """집계 기준 일자. stat_date가 비어 있으면 created_at에서 유도한다.""" + if row.stat_date: + return row.stat_date + return timezone.localtime(row.created_at).date() diff --git a/custom_middlewares/middlewares/statistics.py b/custom_middlewares/middlewares/statistics.py index 4e8fd37..9e00392 100644 --- a/custom_middlewares/middlewares/statistics.py +++ b/custom_middlewares/middlewares/statistics.py @@ -1,7 +1,7 @@ import logging from django.conf import settings -from django.db import transaction +from django.db import DatabaseError, IntegrityError, transaction from django.db.models import F from django.utils import timezone @@ -9,73 +9,131 @@ logger = logging.getLogger(getattr(settings, "COMMON_LOGGER", "django")) +DEFAULT_EXCLUDED_PATH_PREFIXES = ( + "/admin/", + "/static/", + "/media/", + "/silk/", + "/__debug__/", + "/favicon.ico", + "/robots.txt", + "/sitemap.xml", + "/sitemap-", +) + + +def increment_daily_counter(model, field_name: str) -> None: + """일자별 집계 row의 카운터 1개를 원자적으로 증가시킨다. + + `get_or_create(created_at__date=...)`는 생성 시 조회 조건을 컬럼에 반영할 수 + 없어 같은 날짜 row가 중복 생성되고, 한 번 중복이 생기면 이후 모든 요청에서 + `MultipleObjectsReturned`가 발생했다. `stat_date` 유니크 컬럼을 집계키로 쓰고 + UPDATE -> (없으면) INSERT -> (경합 시) UPDATE 순서로 처리해 중복을 구조적으로 + 막는다. UPDATE 한 문장이 원자적이므로 select_for_update 잠금이 필요 없다. + """ + today = timezone.localdate() + + updated = model.objects.filter(stat_date=today).update( + **{field_name: F(field_name) + 1} + ) + if updated: + return + + try: + # 유니크 제약 위반이 바깥 트랜잭션을 깨뜨리지 않도록 savepoint로 감싼다. + with transaction.atomic(): + model.objects.create(stat_date=today, **{field_name: 1}) + except IntegrityError: + # 다른 요청이 먼저 오늘 row를 만든 경우. 유니크 제약이 중복을 막아준다. + model.objects.filter(stat_date=today).update( + **{field_name: F(field_name) + 1} + ) + + +class BaseStatsMiddleware: + """통계 미들웨어 공통 동작. + + 통계 집계는 부가 기능이므로 DB 오류가 사용자 요청 실패로 번지지 않게 한다. + """ -class ConnectionMethodStatsMiddleware: def __init__(self, get_response): self.get_response = get_response - - def stats(self, os_info): - today = timezone.now().date() - with transaction.atomic(): - # 오늘 날짜의 통계 가져오기 (잠금) - ( - stats, - created, - ) = ConnectionMethodStats.objects.select_for_update().get_or_create( - created_at__date=today + self.excluded_prefixes = tuple( + getattr( + settings, + "STATS_EXCLUDED_PATH_PREFIXES", + DEFAULT_EXCLUDED_PATH_PREFIXES, ) + ) - # 운영체제에 따라 카운트 업데이트 - if "Windows" in os_info: - stats.win = F("win") + 1 - elif "mac" in os_info: - stats.mac = F("mac") + 1 - elif "iPhone" in os_info: - stats.iph = F("iph") + 1 - elif "Android" in os_info: - stats.android = F("android") + 1 - else: - stats.oth = F("oth") + 1 - - stats.save() # 변경 사항 저장 + def is_excluded(self, request) -> bool: + return request.path_info.startswith(self.excluded_prefixes) def __call__(self, request): - if "HTTP_USER_AGENT" in request.META: - if "admin" not in request.path: - self.stats(request.META["HTTP_USER_AGENT"]) + if not self.is_excluded(request): + try: + self.record(request) + except DatabaseError as error: + logger.warning( + "failed to record connection stats", + extra={ + "middleware": self.__class__.__name__, + "path": request.path_info, + "error": str(error), + }, + ) - response = self.get_response(request) + return self.get_response(request) - return response + def record(self, request) -> None: # pragma: no cover - 하위 클래스에서 구현 + raise NotImplementedError -class ConnectionHardwareStatsMiddleware: - def __init__(self, get_response): - self.get_response = get_response +class ConnectionMethodStatsMiddleware(BaseStatsMiddleware): + """운영체제별 일일 접속 통계.""" - def __call__(self, request): - if "admin" not in request.path: - with transaction.atomic(): - today = timezone.now().date() - # 오늘 날짜의 통계 가져오기 - ( - stats, - created, - ) = ConnectionHardwareStats.objects.select_for_update().get_or_create( - created_at__date=today - ) - # 사용자 에이전트에 따라 카운트 업데이트 - if request.user_agent.is_mobile: - stats.mobile = F("mobile") + 1 - elif request.user_agent.is_tablet: - stats.tablet = F("tablet") + 1 - elif request.user_agent.is_pc: - stats.pc = F("pc") + 1 - elif request.user_agent.is_bot: - stats.bot = F("bot") + 1 + @staticmethod + def resolve_field(os_info: str) -> str: + if "Windows" in os_info: + return "win" + if "mac" in os_info: + return "mac" + if "iPhone" in os_info: + return "iph" + if "Android" in os_info: + return "android" + return "oth" + + def record(self, request) -> None: + os_info = request.META.get("HTTP_USER_AGENT") + if not os_info: + return + + increment_daily_counter(ConnectionMethodStats, self.resolve_field(os_info)) + + +class ConnectionHardwareStatsMiddleware(BaseStatsMiddleware): + """기기 종류별 일일 접속 통계.""" + + @staticmethod + def resolve_field(user_agent) -> str | None: + if user_agent.is_mobile: + return "mobile" + if user_agent.is_tablet: + return "tablet" + if user_agent.is_pc: + return "pc" + if user_agent.is_bot: + return "bot" + return None - stats.save() # 변경 사항 저장 + def record(self, request) -> None: + user_agent = getattr(request, "user_agent", None) + if user_agent is None: + return - response = self.get_response(request) + field_name = self.resolve_field(user_agent) + if field_name is None: + return - return response + increment_daily_counter(ConnectionHardwareStats, field_name) diff --git a/custom_middlewares/models.py b/custom_middlewares/models.py index e92b02f..1dd1304 100644 --- a/custom_middlewares/models.py +++ b/custom_middlewares/models.py @@ -7,36 +7,61 @@ logger = logging.getLogger(getattr(settings, "COMMON_LOGGER", "django")) -class ConnectionMethodStats(models.Model): +class DailyStatsMixin(models.Model): + """일자 단위 집계 테이블 공통 필드. + + `created_at`은 row가 처음 만들어진 시각일 뿐 집계키가 아니다. + 집계키는 `stat_date`이고 유니크 제약으로 같은 날짜 중복 row를 DB가 막는다. + + `stat_date`가 nullable인 이유: + 이 프로젝트는 migration 파일을 저장소에 두지 않는다(.gitignore). 기존 운영 + 테이블에 not-null unique 컬럼을 한 번에 추가하면 모든 기존 row가 같은 기본값을 + 받아 유니크 제약에 걸린다. nullable로 추가한 뒤 + `manage.py dedupe_connection_stats`로 중복 합산과 backfill을 수행한다. + """ + + stat_date = models.DateField( + null=True, + blank=True, + unique=True, + verbose_name=_("Stat Date"), + ) + created_at = models.DateTimeField( + auto_now_add=True, null=False, verbose_name=_("Created Time") + ) + + class Meta: + abstract = True + + +class ConnectionMethodStats(DailyStatsMixin): win = models.IntegerField(default=0, verbose_name=_("windows")) mac = models.IntegerField(default=0, verbose_name=_("mac")) iph = models.IntegerField(default=0, verbose_name=_("iphone")) android = models.IntegerField(default=0, verbose_name=_("android")) oth = models.IntegerField(default=0, verbose_name=_("others")) - created_at = models.DateTimeField( - auto_now_add=True, null=False, verbose_name=_("Created Time") - ) + + COUNTER_FIELDS = ("win", "mac", "iph", "android", "oth") class Meta: app_label = "home" db_table = "connection_method_stats" verbose_name = _("connection method stats") verbose_name_plural = _("connection method stats") - ordering = ["-created_at"] + ordering = ["-stat_date"] -class ConnectionHardwareStats(models.Model): +class ConnectionHardwareStats(DailyStatsMixin): mobile = models.IntegerField(default=0, verbose_name=_("Mobile")) tablet = models.IntegerField(default=0, verbose_name=_("Tablet")) pc = models.IntegerField(default=0, verbose_name=_("PC")) bot = models.IntegerField(default=0, verbose_name=_("Bot")) - created_at = models.DateTimeField( - auto_now_add=True, null=False, verbose_name=_("Created Time") - ) + + COUNTER_FIELDS = ("mobile", "tablet", "pc", "bot") class Meta: app_label = "home" db_table = "connection_hardware_stats" verbose_name = _("connection hardware stats") verbose_name_plural = _("connection hardware stats") - ordering = ["-created_at"] + ordering = ["-stat_date"] diff --git a/custom_middlewares/tests.py b/custom_middlewares/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/custom_middlewares/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/custom_middlewares/tests/__init__.py b/custom_middlewares/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/custom_middlewares/tests/test_dedupe_command.py b/custom_middlewares/tests/test_dedupe_command.py new file mode 100644 index 0000000..5ffcecd --- /dev/null +++ b/custom_middlewares/tests/test_dedupe_command.py @@ -0,0 +1,107 @@ +from datetime import date, datetime, timezone as dt_timezone +from io import StringIO + +import pytest +from django.core.management import call_command + +from custom_middlewares.models import ( + ConnectionHardwareStats, + ConnectionMethodStats, +) + +pytestmark = [pytest.mark.middlewares, pytest.mark.django_db] + +DAY_ONE = date(2026, 8, 5) +DAY_TWO = date(2026, 8, 6) + + +def make_legacy_row(model, created_on: date, **counters): + """stat_date가 없던 시절의 row를 재현한다.""" + row = model.objects.create(**counters) + model.objects.filter(pk=row.pk).update( + stat_date=None, + created_at=datetime( + created_on.year, + created_on.month, + created_on.day, + 12, + 0, + tzinfo=dt_timezone.utc, + ), + ) + return row + + +def run_command(*args): + out = StringIO() + call_command("dedupe_connection_stats", *args, stdout=out) + return out.getvalue() + + +def test_duplicates_are_merged_and_counts_are_summed(): + make_legacy_row(ConnectionMethodStats, DAY_ONE, win=3, oth=1) + make_legacy_row(ConnectionMethodStats, DAY_ONE, win=4, mac=2) + + run_command() + + stats = ConnectionMethodStats.objects.get() + assert stats.stat_date == DAY_ONE + assert stats.win == 7 + assert stats.mac == 2 + assert stats.oth == 1 + + +def test_different_dates_are_not_merged(): + make_legacy_row(ConnectionMethodStats, DAY_ONE, win=1) + make_legacy_row(ConnectionMethodStats, DAY_ONE, win=2) + make_legacy_row(ConnectionMethodStats, DAY_TWO, win=5) + + run_command() + + assert ConnectionMethodStats.objects.count() == 2 + assert ConnectionMethodStats.objects.get(stat_date=DAY_ONE).win == 3 + assert ConnectionMethodStats.objects.get(stat_date=DAY_TWO).win == 5 + + +def test_stat_date_is_backfilled_for_single_rows(): + make_legacy_row(ConnectionHardwareStats, DAY_ONE, pc=9) + + run_command() + + stats = ConnectionHardwareStats.objects.get() + assert stats.stat_date == DAY_ONE + assert stats.pc == 9 + + +def test_command_is_idempotent(): + make_legacy_row(ConnectionMethodStats, DAY_ONE, win=3) + make_legacy_row(ConnectionMethodStats, DAY_ONE, win=4) + + run_command() + run_command() + + assert ConnectionMethodStats.objects.count() == 1 + assert ConnectionMethodStats.objects.get().win == 7 + + +def test_dry_run_changes_nothing(): + make_legacy_row(ConnectionMethodStats, DAY_ONE, win=3) + make_legacy_row(ConnectionMethodStats, DAY_ONE, win=4) + + output = run_command("--dry-run") + + assert ConnectionMethodStats.objects.count() == 2 + assert ConnectionMethodStats.objects.filter(stat_date=None).count() == 2 + assert "dry-run" in output + + +def test_both_tables_are_processed(): + make_legacy_row(ConnectionMethodStats, DAY_ONE, win=1) + make_legacy_row(ConnectionMethodStats, DAY_ONE, win=1) + make_legacy_row(ConnectionHardwareStats, DAY_ONE, pc=1) + make_legacy_row(ConnectionHardwareStats, DAY_ONE, pc=1) + + run_command() + + assert ConnectionMethodStats.objects.get().win == 2 + assert ConnectionHardwareStats.objects.get().pc == 2 diff --git a/custom_middlewares/tests/test_statistics.py b/custom_middlewares/tests/test_statistics.py new file mode 100644 index 0000000..09d3c38 --- /dev/null +++ b/custom_middlewares/tests/test_statistics.py @@ -0,0 +1,160 @@ +from unittest import mock + +import pytest +from django.db import DatabaseError, IntegrityError, transaction +from django.utils import timezone + +from custom_middlewares.middlewares.statistics import ( + ConnectionHardwareStatsMiddleware, + ConnectionMethodStatsMiddleware, + increment_daily_counter, +) +from custom_middlewares.models import ( + ConnectionHardwareStats, + ConnectionMethodStats, +) + +WINDOWS_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36" +) +HEADERS = {"HTTP_USER_AGENT": WINDOWS_UA} + +# home:index는 sqlite에서 union+slice 조합 때문에 쓸 수 없다(home.tests.test_home_html 참고). +NORMAL_URL = "/portfolio/" + +pytestmark = pytest.mark.middlewares + + +@pytest.mark.django_db +def test_increment_creates_single_row_for_today(): + increment_daily_counter(ConnectionMethodStats, "win") + + stats = ConnectionMethodStats.objects.get() + assert stats.stat_date == timezone.localdate() + assert stats.win == 1 + + +@pytest.mark.django_db +def test_repeated_increments_reuse_the_same_row(): + for _ in range(5): + increment_daily_counter(ConnectionMethodStats, "win") + increment_daily_counter(ConnectionMethodStats, "mac") + + assert ConnectionMethodStats.objects.count() == 1 + stats = ConnectionMethodStats.objects.get() + assert stats.win == 5 + assert stats.mac == 1 + + +@pytest.mark.django_db +def test_stat_date_unique_constraint_blocks_duplicates(): + today = timezone.localdate() + ConnectionMethodStats.objects.create(stat_date=today) + + with pytest.raises(IntegrityError): + with transaction.atomic(): + ConnectionMethodStats.objects.create(stat_date=today) + + +@pytest.mark.django_db +def test_increment_recovers_from_concurrent_create(): + """다른 요청이 오늘 row를 먼저 만든 경합 상황을 재현한다. + + 이전 구현은 이 상황에서 같은 날짜 row가 하나 더 생겼고, 그 뒤로는 모든 요청이 + MultipleObjectsReturned로 실패했다. + """ + today = timezone.localdate() + ConnectionMethodStats.objects.create(stat_date=today, win=5) + + real_filter = ConnectionMethodStats.objects.filter + call_count = [] + + def first_update_finds_nothing(*args, **kwargs): + call_count.append(1) + if len(call_count) == 1: + # 첫 UPDATE 시점에는 오늘 row가 아직 없었다고 가정한다. + return real_filter(pk=0) + return real_filter(*args, **kwargs) + + with mock.patch.object( + ConnectionMethodStats.objects, "filter", first_update_finds_nothing + ), mock.patch.object( + ConnectionMethodStats.objects, + "create", + mock.Mock(side_effect=IntegrityError("duplicate stat_date")), + ): + increment_daily_counter(ConnectionMethodStats, "win") + + assert ConnectionMethodStats.objects.count() == 1 + assert ConnectionMethodStats.objects.get().win == 6 + + +@pytest.mark.parametrize( + "user_agent,expected", + [ + (WINDOWS_UA, "win"), + ("Mozilla/5.0 (Macintosh; Intel mac OS X 10_15_7)", "mac"), + ("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)", "iph"), + ("Mozilla/5.0 (Linux; Android 14; Pixel 8)", "android"), + ("ClaudeBot/1.0", "oth"), + ], +) +def test_method_field_resolution(user_agent, expected): + assert ConnectionMethodStatsMiddleware.resolve_field(user_agent) == expected + + +@pytest.mark.django_db +def test_request_records_both_statistics(client): + client.get(NORMAL_URL, **HEADERS) + + assert ConnectionMethodStats.objects.get().win == 1 + assert ConnectionHardwareStats.objects.get().pc == 1 + + +@pytest.mark.django_db +def test_repeated_requests_do_not_duplicate_rows(client): + for _ in range(3): + client.get(NORMAL_URL, **HEADERS) + + assert ConnectionMethodStats.objects.count() == 1 + assert ConnectionHardwareStats.objects.count() == 1 + assert ConnectionMethodStats.objects.get().win == 3 + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "path", ["/admin/", "/static/css/style.css", "/robots.txt", "/sitemap.xml"] +) +def test_excluded_paths_are_not_counted(client, path): + client.get(path, **HEADERS) + + assert ConnectionMethodStats.objects.count() == 0 + assert ConnectionHardwareStats.objects.count() == 0 + + +@pytest.mark.django_db +def test_path_containing_admin_is_still_counted(client): + """이전 구현은 'admin' 부분 문자열만 보고 정상 URL까지 통계에서 제외했다.""" + client.get("/blog/django-admin-tips/", **HEADERS) + + assert ConnectionMethodStats.objects.count() == 1 + + +@pytest.mark.django_db +def test_database_error_does_not_break_the_request(client): + with mock.patch( + "custom_middlewares.middlewares.statistics.increment_daily_counter", + side_effect=DatabaseError("stats table is unavailable"), + ): + response = client.get(NORMAL_URL, **HEADERS) + + assert response.status_code == 200 + + +def test_hardware_field_resolution_returns_none_for_unknown_agent(): + unknown = mock.Mock( + is_mobile=False, is_tablet=False, is_pc=False, is_bot=False + ) + + assert ConnectionHardwareStatsMiddleware.resolve_field(unknown) is None diff --git a/pytest.ini b/pytest.ini index 88dcae0..909546b 100644 --- a/pytest.ini +++ b/pytest.ini @@ -10,6 +10,8 @@ markers = home: Tests related to home blog: Tests related to blog board: Tests related to board + portfolio: Tests related to portfolio + middlewares: Tests related to custom middlewares python_files = tests.py test_*.py *_tests.py #filterwarnings = ignore::pytest.PytestConfigWarning From 7ab6a425eaef89bbedca7d934956ca5e95e7f2c3 Mon Sep 17 00:00:00 2001 From: bluebamus Date: Mon, 10 Aug 2026 04:05:06 +0000 Subject: [PATCH 04/12] feat(middlewares): block scanner paths before they reach statistics PHP/WordPress probes such as /wp.php and /bbs/board.php reached Django, and because the statistics middleware runs before URL resolution, each probe also took a DB write path and produced Sentry events. BlockSuspiciousPathMiddleware sits right after SecurityMiddleware and ahead of the statistics middlewares, returning an empty-bodied 404 so blocked requests touch neither the statistics tables nor the resolver. It is a fallback for the nginx rules in docs/ai-docs/security/02-nginx-php-scan-blocking.md, not a replacement: Django cannot drop a connection the way nginx's `return 444` does. This commit also adds the tunables the remediation introduces to base.py: BLOCK_SUSPICIOUS_PATHS, SUSPICIOUS_PATH_RESPONSE_STATUS, SUSPICIOUS_PATH_PATTERNS, STATS_EXCLUDED_PATH_PREFIXES, EMAIL_DNS_VALIDATION and EMAIL_DNS_VALIDATION_TIMEOUT. Co-Authored-By: Claude Opus 5 (1M context) --- config/settings/base.py | 27 +++- .../middlewares/access_guard.py | 62 +++++++++ custom_middlewares/tests/test_access_guard.py | 119 ++++++++++++++++++ 3 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 custom_middlewares/middlewares/access_guard.py create mode 100644 custom_middlewares/tests/test_access_guard.py diff --git a/config/settings/base.py b/config/settings/base.py index 93c7da4..68f46dc 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -72,6 +72,8 @@ MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", + # nginx 차단을 통과한 탐색성 요청을 통계/URL resolver 이전에 끊는다 + "custom_middlewares.middlewares.access_guard.BlockSuspiciousPathMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.locale.LocaleMiddleware", "django.middleware.common.CommonMiddleware", @@ -203,4 +205,27 @@ ACCOUNT_SIGNUP_REDIRECT_URL = "users:profile" SMTP_HOST=config("SMTP_HOST","devspoon.com") -SMTP_FROM_ADDRESS=config("SMTP_FROM_ADDRESS","admin@devspoon.com") \ No newline at end of file +SMTP_FROM_ADDRESS=config("SMTP_FROM_ADDRESS","admin@devspoon.com") + +# 탐색성 경로 차단 (custom_middlewares.middlewares.access_guard) +# nginx가 1차로 끊는 것이 원칙이고, 아래 설정은 애플리케이션 fallback이다. +BLOCK_SUSPICIOUS_PATHS = True +SUSPICIOUS_PATH_RESPONSE_STATUS = 404 + +# 접속 통계 집계에서 제외할 경로 prefix (custom_middlewares.middlewares.statistics) +STATS_EXCLUDED_PATH_PREFIXES = [ + "/admin/", + "/static/", + "/media/", + "/silk/", + "/__debug__/", + "/favicon.ico", + "/robots.txt", + "/sitemap.xml", + "/sitemap-", +] + +# 문의 폼 이메일 DNS(MX) 검증 사용 여부. +# 외부 DNS에 의존하므로 테스트 환경에서는 비활성화한다. +EMAIL_DNS_VALIDATION = True +EMAIL_DNS_VALIDATION_TIMEOUT = 10 \ No newline at end of file diff --git a/custom_middlewares/middlewares/access_guard.py b/custom_middlewares/middlewares/access_guard.py new file mode 100644 index 0000000..268e028 --- /dev/null +++ b/custom_middlewares/middlewares/access_guard.py @@ -0,0 +1,62 @@ +import logging +import re + +from django.conf import settings +from django.http import HttpResponse + +logger = logging.getLogger(getattr(settings, "COMMON_LOGGER", "django")) + +""" +nginx에서 1차로 끊지 못한 비서비스 요청을 애플리케이션 초입에서 2차로 차단한다. +이 미들웨어는 nginx 차단 설정의 대체물이 아니라 fallback이다. + +- 이 서비스는 PHP를 실행하지 않으므로 `.php` 계열 요청은 전부 탐색성 트래픽이다. +- 통계 미들웨어보다 앞에 두어야 차단 요청이 통계 DB write 경로를 타지 않는다. +- Django는 nginx의 `return 444`처럼 연결을 즉시 drop할 수 없으므로 조기 404를 반환한다. +""" + +DEFAULT_SUSPICIOUS_PATH_PATTERNS = [ + # WordPress 관리자/리소스 디렉터리 탐색 + r"(^|/)(wp-admin|wp-content|wp-includes)(/|$)", + # 잘 알려진 WordPress/PHP 진입점 + r"(^|/)(phpinfo|xmlrpc|wp-login|wp-config|wp-cron|wp-load|wp-mail" + r"|wp-settings|wp-signup|wp-trackback|wp)\.php$", + # 나머지 모든 .php 요청. 이 서비스는 PHP를 실행하지 않는다. + r"\.php(?:/|$)", + # 설정/자격증명 파일 유출 스캔 + r"(^|/)\.(env|git|aws|ssh)(/|$)", +] + + +class BlockSuspiciousPathMiddleware: + """서비스와 무관한 탐색성 경로를 URL resolver 이전에 차단한다.""" + + def __init__(self, get_response): + self.get_response = get_response + self.enabled = getattr(settings, "BLOCK_SUSPICIOUS_PATHS", True) + self.status = getattr(settings, "SUSPICIOUS_PATH_RESPONSE_STATUS", 404) + raw_patterns = getattr( + settings, + "SUSPICIOUS_PATH_PATTERNS", + DEFAULT_SUSPICIOUS_PATH_PATTERNS, + ) + self.patterns = [ + re.compile(pattern, re.IGNORECASE) for pattern in raw_patterns + ] + + def __call__(self, request): + if self.enabled and self.is_suspicious_path(request.path_info): + logger.info( + "blocked suspicious path", + extra={ + "path": request.path_info, + "method": request.method, + "status": self.status, + }, + ) + return HttpResponse(status=self.status) + + return self.get_response(request) + + def is_suspicious_path(self, path: str) -> bool: + return any(pattern.search(path) for pattern in self.patterns) diff --git a/custom_middlewares/tests/test_access_guard.py b/custom_middlewares/tests/test_access_guard.py new file mode 100644 index 0000000..5532419 --- /dev/null +++ b/custom_middlewares/tests/test_access_guard.py @@ -0,0 +1,119 @@ +import pytest +from django.http import HttpResponse +from django.test import override_settings +from custom_middlewares.middlewares.access_guard import ( + BlockSuspiciousPathMiddleware, +) +from custom_middlewares.models import ( + ConnectionHardwareStats, + ConnectionMethodStats, +) + +HEADERS = { + "HTTP_USER_AGENT": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36" + ) +} + +# home:index는 sqlite에서 union+slice 조합 때문에 쓸 수 없다(home.tests.test_home_html 참고). +NORMAL_URL = "/portfolio/" + +# Sentry에 실제로 기록된 스캔 경로들 +SUSPICIOUS_PATHS = [ + "/wp.php", + "/site/phpinfo.php", + "/public_html/phpinfo.php", + "/wp-admin/phpinfo.php", + "/includes/phpinfo.php", + "/bbs/board.php", + "/xmlrpc.php", + "/wp-login.php", + "/wp-content/uploads/shell.php", + "/WP-ADMIN/", + "/.env", +] + + +@pytest.mark.middlewares +@pytest.mark.django_db +@pytest.mark.parametrize("path", SUSPICIOUS_PATHS) +def test_suspicious_path_is_blocked_early(client, path): + response = client.get(path, **HEADERS) + + assert response.status_code == 404 + # 조기 차단은 본문 없이 끝난다. URL resolver까지 갔다면 404 에러 페이지가 렌더된다. + assert response.content == b"" + + +@pytest.mark.middlewares +@pytest.mark.django_db +def test_blocked_path_does_not_touch_statistics(client): + client.get("/bbs/board.php?bo_table=free&wr_id=1718", **HEADERS) + + assert ConnectionMethodStats.objects.count() == 0 + assert ConnectionHardwareStats.objects.count() == 0 + + +@pytest.mark.middlewares +@pytest.mark.django_db +def test_normal_path_is_not_blocked(client): + response = client.get(NORMAL_URL, **HEADERS) + + assert response.status_code == 200 + + +@pytest.mark.middlewares +@pytest.mark.django_db +def test_unknown_normal_path_still_renders_error_page(client): + """차단 대상이 아닌 오탈자 URL은 기존 404 페이지를 유지한다.""" + response = client.get("/does-not-exist.html", **HEADERS) + + assert response.status_code == 404 + assert b"404 Error" in response.content + + +@pytest.mark.middlewares +@pytest.mark.parametrize( + "path", + [ + "/", + "/portfolio/", + "/blog/", + "/board/", + "/users/login/", + "/static/css/style.css", + "/phpinfo", # 확장자 없는 경로는 차단 대상이 아니다 + "/blog/phpstorm-review/", + ], +) +def test_service_paths_are_not_suspicious(path): + middleware = BlockSuspiciousPathMiddleware(lambda request: HttpResponse()) + + assert middleware.is_suspicious_path(path) is False + + +@pytest.mark.middlewares +@override_settings(BLOCK_SUSPICIOUS_PATHS=False) +def test_guard_can_be_disabled(rf): + sentinel = HttpResponse(status=200) + middleware = BlockSuspiciousPathMiddleware(lambda request: sentinel) + + assert middleware(rf.get("/wp.php")) is sentinel + + +@pytest.mark.middlewares +@override_settings(SUSPICIOUS_PATH_RESPONSE_STATUS=403) +def test_block_status_is_configurable(rf): + middleware = BlockSuspiciousPathMiddleware(lambda request: HttpResponse()) + + assert middleware(rf.get("/wp.php")).status_code == 403 + + +@pytest.mark.middlewares +@override_settings(SUSPICIOUS_PATH_PATTERNS=[r"^/blocked/"]) +def test_patterns_are_configurable(rf): + middleware = BlockSuspiciousPathMiddleware(lambda request: HttpResponse()) + + assert middleware.is_suspicious_path("/blocked/here") is True + assert middleware.is_suspicious_path("/wp.php") is False From f5b19f54cff0b6832a46042c967356ff112afc74 Mon Sep 17 00:00:00 2001 From: bluebamus Date: Mon, 10 Aug 2026 04:05:22 +0000 Subject: [PATCH 05/12] fix(portfolio): validate get-in-touch input and separate receipt from delivery The view read request.POST directly and saved without validating, so a phone number longer than the varchar(16) column reached the INSERT (Sentry PYTHON-DJANGO-7B). GetInTouchForm now holds the input policy in one place, matching the model column constraints. Mail handling is reworked so an outage cannot be mistaken for success: - store the inquiry first, then send, so a vendor failure never loses it - send_mail_sync() returns whether delivery succeeded; the thread-based send_mail() is kept for verify_email_mixins and now shares its body - GetInTouchLog.status (received/queued/sent/failed) records the mail result, while state keeps its meaning as "the inquiry was received" - on failure the user is told the message was received but the mail is delayed, instead of being shown a success message - recipients are masked in logs and Sentry Other fixes in the same path: - remove six unreachable duplicate save blocks after the first return - move DNS validation into clean_emailfrom(), which also fixes the uninitialised is_valid returned from the old exception path - treat DNS timeout / nameserver failures as "cannot verify" and let the address through, instead of rejecting the user as before; missing MX records are still a rejection - pin the recipient to settings.DEFAULT_FROM_EMAIL rather than the emailto hidden input - replace the inline phone regex, whose [0|1|6|7|8|9] character class literally allowed "|", with a shared validator - mirror the server-side limits as maxlength/required on the template Tests use the locmem mail backend and skip DNS lookups. Co-Authored-By: Claude Opus 5 (1M context) --- config/settings/test.py | 4 + portfolio/admin.py | 5 +- portfolio/forms.py | 116 ++++++++++++ portfolio/models.py | 26 ++- portfolio/tests.py | 3 - portfolio/tests/__init__.py | 0 portfolio/tests/test_email_sending.py | 91 ++++++++++ portfolio/tests/test_get_in_touch_form.py | 136 +++++++++++++++ portfolio/tests/test_get_in_touch_view.py | 90 ++++++++++ portfolio/validators.py | 15 ++ portfolio/views.py | 204 +++++----------------- templates/portfolio/portfolio.html | 10 +- utils/email/async_send_email.py | 106 ++++++++--- 13 files changed, 613 insertions(+), 193 deletions(-) create mode 100644 portfolio/forms.py delete mode 100644 portfolio/tests.py create mode 100644 portfolio/tests/__init__.py create mode 100644 portfolio/tests/test_email_sending.py create mode 100644 portfolio/tests/test_get_in_touch_form.py create mode 100644 portfolio/tests/test_get_in_touch_view.py create mode 100644 portfolio/validators.py diff --git a/config/settings/test.py b/config/settings/test.py index 5a224aa..003e464 100644 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -99,6 +99,10 @@ AUTH_USER_MODEL = "users.User" +# 테스트는 외부 DNS/메일 벤더에 의존하지 않는다. +EMAIL_DNS_VALIDATION = False +EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" + # reference blog : https://velog.io/@kim6515516/Django-silk-%EC%84%B1%EB%8A%A5-%ED%94%84%EB%A1%9C%ED%8C%8C%EC%9D%BC%EB%9F%AC # reference github : https://github.com/jazzband/django-silk diff --git a/portfolio/admin.py b/portfolio/admin.py index ace3605..9b9c7ff 100644 --- a/portfolio/admin.py +++ b/portfolio/admin.py @@ -317,8 +317,9 @@ def delete_model(self, request, obj): class GetInTouchLogAdmin(admin.ModelAdmin): - list_display = ['name', 'email', 'subject', 'state', 'created_at'] - list_filter = ['state'] + # state는 접수 성공 여부, status는 알림 메일 발송 결과다. + list_display = ['name', 'email', 'subject', 'state', 'status', 'created_at'] + list_filter = ['state', 'status'] search_fields = ['name', 'email', 'subject'] ordering = ['-created_at'] readonly_fields = ['created_at'] diff --git a/portfolio/forms.py b/portfolio/forms.py new file mode 100644 index 0000000..d805cdf --- /dev/null +++ b/portfolio/forms.py @@ -0,0 +1,116 @@ +import logging + +from django import forms +from django.conf import settings +from django.utils.translation import gettext_lazy as _ +from validate_email import validate_email +from validate_email.exceptions import ( + DNSConfigurationError, + DNSTimeoutError, + Error, + NoNameserverError, +) + +from .validators import PHONE_NUMBER_MAX_LENGTH, korean_mobile_number_validator + +logger = logging.getLogger(getattr(settings, "PORTFOLIO_LOGGER", "django")) + +NAME_MAX_LENGTH = 300 +SUBJECT_MAX_LENGTH = 300 +EMAIL_MAX_LENGTH = 128 +MESSAGE_MAX_LENGTH = 5000 + +INVALID_EMAIL_MESSAGE = _( + "The email failed validation. Please enter the email address you actually use" +) + +# DNS 조회 자체가 불가능한 경우. 이메일이 틀린 것이 아니라 검증을 못 한 것이므로 +# 정상 사용자를 막지 않고 통과시킨다. +DNS_INFRASTRUCTURE_ERRORS = ( + DNSTimeoutError, + DNSConfigurationError, + NoNameserverError, +) + + +class GetInTouchForm(forms.Form): + """포트폴리오 문의 폼. + + 이전 구현은 뷰에서 request.POST를 직접 읽고 저장 전에 길이/형식 검증을 하지 + 않아, 16자를 초과한 전화번호가 DB insert까지 도달해 DataError를 냈다. + 입력 정책을 이 폼 한 곳에 모아 모델 컬럼 제약과 일치시킨다. + """ + + name = forms.CharField( + max_length=NAME_MAX_LENGTH, + strip=True, + error_messages={"required": _("Name can't be empty.")}, + ) + emailfrom = forms.EmailField( + max_length=EMAIL_MAX_LENGTH, + error_messages={"required": _("email can't be empty.")}, + ) + number = forms.CharField( + required=False, + max_length=PHONE_NUMBER_MAX_LENGTH, + strip=True, + validators=[korean_mobile_number_validator], + ) + subject = forms.CharField( + max_length=SUBJECT_MAX_LENGTH, + strip=True, + error_messages={"required": _("subject can't be empty.")}, + ) + message = forms.CharField( + max_length=MESSAGE_MAX_LENGTH, + strip=True, + widget=forms.Textarea, + error_messages={"required": _("message can't be empty.")}, + ) + + def clean_emailfrom(self) -> str: + email = self.cleaned_data["emailfrom"] + _local, domain = email.rsplit("@", 1) + + if "test" in domain.lower(): + raise forms.ValidationError( + _("Incorrect domain. Please enter the domain you actually use.") + ) + + if not getattr(settings, "EMAIL_DNS_VALIDATION", True): + return email + + try: + is_valid = validate_email( + email_address=email, + check_format=True, # 이메일 형식 검증 + check_blacklist=True, # 블랙리스트 도메인 검증 + check_dns=True, # DNS MX 레코드 검증 + dns_timeout=getattr( + settings, "EMAIL_DNS_VALIDATION_TIMEOUT", 10 + ), + check_smtp=False, # SMTP 연결 통한 실제 이메일 존재 여부 검증 + smtp_timeout=10, + smtp_helo_host=settings.SMTP_HOST, + smtp_from_address=settings.SMTP_FROM_ADDRESS, + smtp_skip_tls=False, + smtp_debug=False, + ) + except DNS_INFRASTRUCTURE_ERRORS as error: + logger.warning( + "email dns validation unavailable", + extra={"error": str(error)}, + ) + return email + except (Error, ValueError) as error: + # 이전 구현은 이 경로에서 초기화되지 않은 is_valid를 반환해 + # UnboundLocalError가 날 수 있었다. + logger.debug( + "email validation failed", extra={"error": str(error)} + ) + raise forms.ValidationError(INVALID_EMAIL_MESSAGE) + + if not is_valid: + raise forms.ValidationError(INVALID_EMAIL_MESSAGE) + + return email diff --git a/portfolio/models.py b/portfolio/models.py index de0ddc7..940635e 100644 --- a/portfolio/models.py +++ b/portfolio/models.py @@ -6,6 +6,10 @@ from django.utils.translation import gettext_lazy as _ from blog.models.blog import ProjectPost +from portfolio.validators import ( + PHONE_NUMBER_MAX_LENGTH, + korean_mobile_number_validator, +) from utils.os.file_path_name_gen import ( date_upload_to_for_file, date_upload_to_for_image, @@ -298,15 +302,27 @@ def __str__(self): class GetInTouchLog(PortfolioMixin): + class MailStatus(models.TextChoices): + RECEIVED = "received", _("Received") + QUEUED = "queued", _("Queued") + SENT = "sent", _("Sent") + FAILED = "failed", _("Failed") + name = models.CharField(blank=False, max_length=300, verbose_name=_("Name")) + # state: 문의가 정상 접수되어 저장되었는지 여부. 메일 발송 성공 여부가 아니다. state = models.BooleanField(blank=False, default=True, verbose_name=_("State")) - email = models.EmailField(max_length=128, blank=False, verbose_name=_("Email")) - phone_number_regex = RegexValidator( - regex=r"^01([0|1|6|7|8|9]?)-?([0-9]{3,4})-?([0-9]{4})$" + # status: 알림 메일의 발송 상태. 접수(state)와 발송 결과를 분리해 기록한다. + status = models.CharField( + max_length=16, + choices=MailStatus.choices, + default=MailStatus.RECEIVED, + db_index=True, + verbose_name=_("Mail Status"), ) + email = models.EmailField(max_length=128, blank=False, verbose_name=_("Email")) phone_number = models.CharField( - validators=[phone_number_regex], - max_length=16, + validators=[korean_mobile_number_validator], + max_length=PHONE_NUMBER_MAX_LENGTH, blank=True, verbose_name=_("Phone Number"), ) diff --git a/portfolio/tests.py b/portfolio/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/portfolio/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/portfolio/tests/__init__.py b/portfolio/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/portfolio/tests/test_email_sending.py b/portfolio/tests/test_email_sending.py new file mode 100644 index 0000000..b1a615d --- /dev/null +++ b/portfolio/tests/test_email_sending.py @@ -0,0 +1,91 @@ +from unittest import mock + +import pytest +from django.core import mail + +from utils.email.async_send_email import ( + mask_email, + mask_recipients, + send_mail_sync, +) + +pytestmark = pytest.mark.portfolio + + +@pytest.mark.parametrize( + "address,expected", + [ + ("hong.gildong@gmail.com", "h***g@gmail.com"), + ("ab@gmail.com", "a*@gmail.com"), + ("a@gmail.com", "a*@gmail.com"), + ("not-an-email", "***"), + ("", "***"), + (None, "***"), + ], +) +def test_mask_email(address, expected): + assert mask_email(address) == expected + + +def test_mask_recipients(): + assert mask_recipients(["hong.gildong@gmail.com"]) == ["h***g@gmail.com"] + assert mask_recipients([]) == [] + assert mask_recipients(None) == [] + + +def test_send_mail_sync_reports_success(): + delivered = send_mail_sync( + subject="hello", + recipient_list=["admin@devspoon.com"], + message="body", + from_email="admin@devspoon.com", + ) + + assert delivered is True + assert len(mail.outbox) == 1 + + +def test_send_mail_sync_reports_vendor_failure(): + """SendGrid 401 / Maximum credits exceeded 같은 외부 실패를 흡수한다.""" + with mock.patch( + "django.core.mail.EmailMultiAlternatives.send", + side_effect=Exception("HTTP Error 401: Unauthorized"), + ): + delivered = send_mail_sync( + subject="hello", + recipient_list=["admin@devspoon.com"], + message="body", + from_email="admin@devspoon.com", + ) + + assert delivered is False + + +def test_send_mail_sync_reports_zero_recipients(): + with mock.patch( + "django.core.mail.EmailMultiAlternatives.send", return_value=0 + ): + delivered = send_mail_sync( + subject="hello", + recipient_list=["admin@devspoon.com"], + message="body", + from_email="admin@devspoon.com", + ) + + assert delivered is False + + +def test_failure_log_does_not_leak_the_recipient(caplog): + with mock.patch( + "django.core.mail.EmailMultiAlternatives.send", + side_effect=Exception("HTTP Error 401: Unauthorized"), + ): + send_mail_sync( + subject="hello", + recipient_list=["hong.gildong@gmail.com"], + message="body", + from_email="admin@devspoon.com", + ) + + logged = [record.recipients for record in caplog.records if hasattr(record, "recipients")] + assert logged == [["h***g@gmail.com"]] diff --git a/portfolio/tests/test_get_in_touch_form.py b/portfolio/tests/test_get_in_touch_form.py new file mode 100644 index 0000000..755242d --- /dev/null +++ b/portfolio/tests/test_get_in_touch_form.py @@ -0,0 +1,136 @@ +import pytest +from django.test import override_settings + +from portfolio.forms import MESSAGE_MAX_LENGTH, GetInTouchForm + +pytestmark = pytest.mark.portfolio + +# Sentry DataError 이벤트에 기록된 것과 같은 형태의 16자 초과 무작위 입력 +TOO_LONG_NUMBER = "8Kd93jfLq02mVzXcPq41" + + +def payload(**overrides): + data = { + "name": "hong gildong", + "emailfrom": "hong@example.com", + "number": "010-1234-5678", + "subject": "project inquiry", + "message": "hello, I would like to talk about a project.", + } + data.update(overrides) + return data + + +@pytest.mark.parametrize( + "number", ["010-1234-5678", "01012345678", "011-234-5678", ""] +) +def test_valid_numbers_are_accepted(number): + form = GetInTouchForm(payload(number=number)) + + assert form.is_valid(), form.errors + + +def test_number_longer_than_column_is_rejected(): + """DataError: value too long for type character varying(16) 재발 방지.""" + form = GetInTouchForm(payload(number=TOO_LONG_NUMBER)) + + assert not form.is_valid() + assert "number" in form.errors + + +@pytest.mark.parametrize( + "number", ["hello-world", "12345", "010-12-34", "+82-10-1234-5678"] +) +def test_malformed_numbers_are_rejected(number): + form = GetInTouchForm(payload(number=number)) + + assert not form.is_valid() + assert "number" in form.errors + + +@pytest.mark.parametrize("field", ["name", "emailfrom", "subject", "message"]) +def test_required_fields(field): + form = GetInTouchForm(payload(**{field: ""})) + + assert not form.is_valid() + assert field in form.errors + + +@pytest.mark.parametrize("field", ["name", "subject"]) +def test_length_limited_fields(field): + form = GetInTouchForm(payload(**{field: "x" * 301})) + + assert not form.is_valid() + assert field in form.errors + + +def test_message_length_is_limited(): + form = GetInTouchForm(payload(message="x" * (MESSAGE_MAX_LENGTH + 1))) + + assert not form.is_valid() + assert "message" in form.errors + + +def test_whitespace_only_input_is_rejected(): + form = GetInTouchForm(payload(name=" ", subject=" ", message=" ")) + + assert not form.is_valid() + assert {"name", "subject", "message"} <= set(form.errors) + + +def test_text_fields_are_trimmed(): + form = GetInTouchForm(payload(name=" hong ", subject=" hi ")) + + assert form.is_valid(), form.errors + assert form.cleaned_data["name"] == "hong" + assert form.cleaned_data["subject"] == "hi" + + +@pytest.mark.parametrize( + "email", ["not-an-email", "missing@domain", "@example.com"] +) +def test_malformed_emails_are_rejected(email): + form = GetInTouchForm(payload(emailfrom=email)) + + assert not form.is_valid() + assert "emailfrom" in form.errors + + +@pytest.mark.parametrize( + "email", ["a@test.com", "a@mytest.co.kr", "a@TEST.io"] +) +def test_test_domains_are_rejected(email): + form = GetInTouchForm(payload(emailfrom=email)) + + assert not form.is_valid() + assert "emailfrom" in form.errors + + +@override_settings(EMAIL_DNS_VALIDATION=True) +def test_dns_failure_does_not_block_a_valid_address(): + """DNS 조회 실패는 '검증 불가'이지 '잘못된 주소'가 아니다.""" + from validate_email.exceptions import DNSTimeoutError + + with pytest.MonkeyPatch.context() as patch: + patch.setattr( + "portfolio.forms.validate_email", + lambda **kwargs: (_ for _ in ()).throw(DNSTimeoutError()), + ) + form = GetInTouchForm(payload()) + + assert form.is_valid(), form.errors + + +@override_settings(EMAIL_DNS_VALIDATION=True) +def test_domain_without_mx_record_is_rejected(): + from validate_email.exceptions import NoMXError + + with pytest.MonkeyPatch.context() as patch: + patch.setattr( + "portfolio.forms.validate_email", + lambda **kwargs: (_ for _ in ()).throw(NoMXError()), + ) + form = GetInTouchForm(payload()) + + assert not form.is_valid() + assert "emailfrom" in form.errors diff --git a/portfolio/tests/test_get_in_touch_view.py b/portfolio/tests/test_get_in_touch_view.py new file mode 100644 index 0000000..aba0fad --- /dev/null +++ b/portfolio/tests/test_get_in_touch_view.py @@ -0,0 +1,90 @@ +from unittest import mock + +import pytest +from django.contrib.messages import constants as message_levels +from django.core import mail +from django.urls import reverse + +from portfolio.models import GetInTouchLog + +from .test_get_in_touch_form import TOO_LONG_NUMBER, payload + +pytestmark = [pytest.mark.portfolio, pytest.mark.django_db] + +HEADERS = { + "HTTP_USER_AGENT": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36" + ) +} + + +def post(client, **overrides): + return client.post(reverse("portfolio:mail"), payload(**overrides), **HEADERS) + + +def message_levels_of(response): + return [message.level for message in response.wsgi_request._messages] + + +def test_valid_inquiry_is_saved_and_mailed(client): + response = post(client) + + assert response.status_code == 302 + log = GetInTouchLog.objects.get() + assert log.state is True + assert log.status == GetInTouchLog.MailStatus.SENT + assert log.phone_number == "010-1234-5678" + assert len(mail.outbox) == 1 + assert "project inquiry" in mail.outbox[0].subject + + +def test_too_long_number_never_reaches_the_database(client): + """DataError 재발 방지: 저장 시도 자체가 일어나지 않아야 한다.""" + response = post(client, number=TOO_LONG_NUMBER) + + assert response.status_code == 302 + assert GetInTouchLog.objects.count() == 0 + assert len(mail.outbox) == 0 + assert message_levels.ERROR in message_levels_of(response) + + +@pytest.mark.parametrize("field", ["name", "emailfrom", "subject", "message"]) +def test_missing_required_field_is_rejected(client, field): + response = post(client, **{field: ""}) + + assert response.status_code == 302 + assert GetInTouchLog.objects.count() == 0 + assert len(mail.outbox) == 0 + + +def test_mail_failure_still_records_the_inquiry(client): + with mock.patch("portfolio.views.send_mail_sync", return_value=False): + response = post(client) + + log = GetInTouchLog.objects.get() + assert log.state is True + # 접수는 성공, 발송은 실패로 구분해 남는다. + assert log.status == GetInTouchLog.MailStatus.FAILED + # 사용자에게는 성공이 아니라 지연으로 안내한다. + assert message_levels.SUCCESS not in message_levels_of(response) + assert message_levels.WARNING in message_levels_of(response) + + +def test_mail_recipient_is_taken_from_settings_not_the_form(client, settings): + post(client, emailto="attacker@example.com") + + assert mail.outbox[0].to == [settings.DEFAULT_FROM_EMAIL] + + +def test_optional_number_can_be_empty(client): + post(client, number="") + + assert GetInTouchLog.objects.get().phone_number == "" + + +def test_only_one_log_row_is_created_per_submission(client): + """이전 구현은 첫 return 뒤에 도달 불가능한 저장 코드가 여러 번 반복됐다.""" + post(client) + + assert GetInTouchLog.objects.count() == 1 diff --git a/portfolio/validators.py b/portfolio/validators.py new file mode 100644 index 0000000..411ee0f --- /dev/null +++ b/portfolio/validators.py @@ -0,0 +1,15 @@ +from django.core.validators import RegexValidator +from django.utils.translation import gettext_lazy as _ + +# 국내 휴대폰 번호. 하이픈은 있어도 되고 없어도 된다. +# 최대 길이는 모델 컬럼(varchar 16)과 폼에서 동일하게 강제한다. +KOREAN_MOBILE_NUMBER_REGEX = r"^01[016789]?-?[0-9]{3,4}-?[0-9]{4}$" + +PHONE_NUMBER_MAX_LENGTH = 16 + +korean_mobile_number_validator = RegexValidator( + regex=KOREAN_MOBILE_NUMBER_REGEX, + message=_( + "Enter a valid mobile number. Example: 010-1234-5678 or 01012345678." + ), +) diff --git a/portfolio/views.py b/portfolio/views.py index 88e2afd..e477951 100644 --- a/portfolio/views.py +++ b/portfolio/views.py @@ -1,33 +1,24 @@ import logging -import re from django.conf import settings from django.contrib import messages -from django.core.cache import cache from django.core.cache.backends.base import DEFAULT_TIMEOUT from django.http import JsonResponse from django.shortcuts import redirect from django.template.loader import render_to_string from django.urls import reverse from django.utils import translation -from django.utils.decorators import method_decorator from django.views.generic import TemplateView, View -from validate_email import validate_email -from validate_email.exceptions import AddressFormatError, Error from common.components.django_redis_cache_components import ( dredis_cache_check_key, dredis_cache_delete, dredis_cache_get, dredis_cache_set) -# from django.core.mail import send_mail -from utils.email.async_send_email import send_mail +from utils.email.async_send_email import send_mail_sync +from .forms import GetInTouchForm from .models import (AboutProjects, EducationStudy, GetInTouchLog, InterestedIn, PersonalInfo, Portfolio, WorkExperience) - # 일반적인 이메일 유효성 오류의 기본 클래스 # MX 레코드 없음 오류 - - - logger = logging.getLogger(getattr(settings, "PORTFOLIO_LOGGER", "django")) CACHE_TTL = getattr(settings, "CACHE_TTL", DEFAULT_TIMEOUT) @@ -172,172 +163,71 @@ def get(self, request, *args, **kwargs): class GetInTouchView(View): email_template_get_in_touch = "/email/get_in_touch.html" - def check_email_validation_with_dns(self, email: str) -> [str, bool]: - try: - if email is None: - return "", False - logger.debug( - "GetInTouchView.check_email_validation_with_dns email :", - extra={"email": email}, - ) - - _, domain = email.rsplit("@", 1) - - if "test" in domain.lower(): - raise AddressFormatError("Incorrect domain. Please enter the domain you actually use.") - - is_valid = validate_email( - email_address=email, - check_format=True, # 이메일 형식 검증 - check_blacklist=True, # 블랙리스트 도메인 검증 - check_dns=True, # DNS MX 레코드 검증 - dns_timeout=10, # DNS 타임아웃 10초 - check_smtp=False, # SMTP 연결 통한 실제 이메일 존재 여부 검증 - smtp_timeout=10, # SMTP 타임아웃 10초 - smtp_helo_host=settings.SMTP_HOST, # SMTP HELO 호스트명 - smtp_from_address=settings.SMTP_FROM_ADDRESS, # SMTP FROM 주소 - smtp_skip_tls=False, # TLS 사용 - smtp_debug=False # 디버그 출력 비활성화 - ) - if not is_valid: - raise Error("The email failed validation. Please enter the email address you actually use") - - logger.debug( - "GetInTouchView email validated" - ) - - return is_valid - - except (Error, ValueError) as e: - logger.debug( - "Error :", - extra={"GetInTouchView.error : ": str(e)}, - ) - return is_valid - def post(self, request, *args, **kwargs): - pattern = re.compile("^[a-zA-Z0-9+-_.]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$") - name = request.POST.get("name", "") - emailfrom = request.POST.get("emailfrom", "") - emailto = request.POST.get("emailto", "") - number = request.POST.get("number", "") - subject = request.POST.get("subject", "") - message = request.POST.get("message", "") - - rt = self.check_email_validation_with_dns(emailfrom) + form = GetInTouchForm(request.POST) - if not rt: + if not form.is_valid(): + for error in form.errors.values(): + messages.error(request, error[0]) logger.debug( - "The email failed validation. Please enter the email address you actually use", - extra={"email : ": emailto}, + "GetInTouchView invalid submission", + extra={"errors": form.errors.get_json_data(escape_html=True)}, ) - messages.error(request, "The email failed validation. Please enter the email address you actually use") return redirect(reverse("portfolio:portfolio")) - if not name: - messages.error(self.request, "Name can't be empty.") - return redirect(reverse("portfolio:portfolio")) + data = form.cleaned_data - if not emailfrom: - messages.error(self.request, "email can't be empty.") - return redirect(reverse("portfolio:portfolio")) + # 문의는 메일 발송 결과와 무관하게 먼저 접수한다. + # 메일 벤더 장애로 문의 자체가 유실되면 안 된다. + log = GetInTouchLog.objects.create( + name=data["name"], + state=True, + status=GetInTouchLog.MailStatus.RECEIVED, + email=data["emailfrom"], + phone_number=data["number"], + subject=data["subject"], + message=data["message"], + ) - if not pattern.match(emailfrom): - messages.error(self.request, "The email format is not correct.") - return redirect(reverse("portfolio:portfolio")) + # 수신자는 hidden input(emailto)이 아니라 서버 설정값으로 고정한다. + delivered = self.send_notification(data) - if not subject: - messages.error(self.request, "subject can't be empty.") - return redirect(reverse("portfolio:portfolio")) + log.status = ( + GetInTouchLog.MailStatus.SENT + if delivered + else GetInTouchLog.MailStatus.FAILED + ) + log.save(update_fields=["status"]) - if not message: - messages.error(self.request, "message can't be empty.") - return redirect(reverse("portfolio:portfolio")) + if delivered: + messages.success(request, "Your email has been successfully delivered.") + else: + # 접수는 성공했으므로 실패로 안내하지 않는다. + messages.warning( + request, + "Your message has been received, but the notification email is " + "delayed. We will get back to you.", + ) + return redirect(reverse("portfolio:portfolio")) + + def send_notification(self, data: dict) -> bool: email_context = { - "name": name, - "emailfrom": emailfrom, - "number": number, - "message": message, + "name": data["name"], + "emailfrom": data["emailfrom"], + "number": data["number"], + "message": data["message"], } msg_html = render_to_string( settings.TEMPLATE_DIR + self.email_template_get_in_touch, email_context ) - subject_email = subject + " - " + emailfrom - send_mail( - subject=subject_email, - message=message, + return send_mail_sync( + subject=f"{data['subject']} - {data['emailfrom']}", + message=data["message"], from_email=settings.DEFAULT_FROM_EMAIL, recipient_list=[settings.DEFAULT_FROM_EMAIL], html_message=msg_html, fail_silently=False, ) - - GetInTouchLog.objects.create( - name=name, - state=True, - email=emailfrom, - phone_number=number, - subject=subject, - message=message, - ) - messages.success(request, "Your email has been successfully delivered.") - return redirect(reverse("portfolio:portfolio")) - - GetInTouchLog.objects.create( - name=name, - state=True, - email=emailfrom, - phone_number=number, - subject=subject, - message=message, - ) - messages.success(request, "Your email has been successfully delivered.") - return redirect(reverse("portfolio:portfolio")) - return redirect(reverse("portfolio:portfolio")) - - GetInTouchLog.objects.create( - name=name, - state=True, - email=emailfrom, - phone_number=number, - subject=subject, - message=message, - ) - messages.success(request, "Your email has been successfully delivered.") - return redirect(reverse("portfolio:portfolio")) - return redirect(reverse("portfolio:portfolio")) - - GetInTouchLog.objects.create( - name=name, - state=True, - email=emailfrom, - phone_number=number, - subject=subject, - message=message, - ) - messages.success(request, "Your email has been successfully delivered.") - return redirect(reverse("portfolio:portfolio")) - GetInTouchLog.objects.create( - name=name, - state=True, - email=emailfrom, - phone_number=number, - subject=subject, - message=message, - ) - messages.success(request, "Your email has been successfully delivered.") - return redirect(reverse("portfolio:portfolio")) - return redirect(reverse("portfolio:portfolio")) - GetInTouchLog.objects.create( - name=name, - state=True, - email=emailfrom, - phone_number=number, - subject=subject, - message=message, - ) - messages.success(request, "Your email has been successfully delivered.") - return redirect(reverse("portfolio:portfolio")) diff --git a/templates/portfolio/portfolio.html b/templates/portfolio/portfolio.html index 4a522f4..96ac34a 100644 --- a/templates/portfolio/portfolio.html +++ b/templates/portfolio/portfolio.html @@ -337,35 +337,35 @@

- +
- +
- +
- +
- +
diff --git a/utils/email/async_send_email.py b/utils/email/async_send_email.py index 813399b..0a48e7b 100644 --- a/utils/email/async_send_email.py +++ b/utils/email/async_send_email.py @@ -1,16 +1,84 @@ -import random -import string -import threading import logging +import threading from django.conf import settings from django.core.mail import EmailMultiAlternatives -from django.core.mail import send_mail as sendmail # 로거 설정 logger = logging.getLogger(__name__) +def mask_email(address: str) -> str: + """로그/Sentry에 원문 이메일이 남지 않도록 local part를 가린다.""" + address = str(address or "") + if "@" not in address: + return "***" + + local, domain = address.rsplit("@", 1) + if len(local) <= 2: + masked_local = (local[0] + "*") if local else "*" + else: + masked_local = f"{local[0]}***{local[-1]}" + + return f"{masked_local}@{domain}" + + +def mask_recipients(recipient_list) -> list: + if not recipient_list: + return [] + return [mask_email(address) for address in recipient_list] + + +def build_message(subject, message, from_email, recipient_list, html): + msg = EmailMultiAlternatives( + subject, message, from_email, to=recipient_list + ) + if html: + msg.attach_alternative(html, "text/html") + return msg + + +def send_mail_sync( + subject, + recipient_list, + *args, + message, + from_email=settings.EMAIL_HOST_USER, + html_message=None, + fail_silently=False, + **kwargs, +) -> bool: + """메일을 동기로 발송하고 성공 여부를 반환한다. + + 호출자가 발송 결과에 따라 사용자 안내와 상태 저장을 분리할 수 있도록, + 예외를 삼키지 않고 bool로 정규화해 돌려준다. + """ + masked = mask_recipients(recipient_list) + msg = build_message( + subject, message, from_email, recipient_list, html_message + ) + + try: + sent_count = msg.send(fail_silently) + except Exception as error: + # 메일 벤더 인증/크레딧 문제 등 외부 요인이 대부분이다. + logger.error( + "Error sending email", + extra={"recipients": masked, "error": str(error)}, + ) + return False + + if sent_count > 0: + logger.info("Email sent successfully", extra={"recipients": masked}) + return True + + logger.warning( + "Email not sent. No recipients were successfully sent.", + extra={"recipients": masked}, + ) + return False + + class EmailThread(threading.Thread): def __init__( self, subject, message, from_email, recipient_list, html, fail_silently @@ -21,26 +89,18 @@ def __init__( self.recipient_list = recipient_list self.fail_silently = fail_silently self.html = html + self.result = None threading.Thread.__init__(self) def run(self): - msg = EmailMultiAlternatives( - self.subject, self.message, self.from_email, to=self.recipient_list + self.result = send_mail_sync( + subject=self.subject, + recipient_list=self.recipient_list, + message=self.message, + from_email=self.from_email, + html_message=self.html, + fail_silently=self.fail_silently, ) - if self.html: - msg.attach_alternative(self.html, "text/html") - - try: - # 이메일 전송 시도 - result = msg.send(self.fail_silently) - # 전송 결과 로그 기록 - if result > 0: - logger.info(f"Email sent successfully to {self.recipient_list}.") - else: - logger.warning(f"Email not sent to {self.recipient_list}. No recipients were successfully sent.") - except Exception as e: - # 예외 발생 시 로그 기록 - logger.error(f"Error sending email to {self.recipient_list}: {e}") def send_mail( @@ -51,8 +111,12 @@ def send_mail( from_email=settings.EMAIL_HOST_USER, html_message=None, fail_silently=False, - **kwargs + **kwargs, ): + """메일을 백그라운드 스레드로 발송한다. + + 호출자는 발송 결과를 알 수 없다. 결과가 필요하면 send_mail_sync를 쓴다. + """ EmailThread( subject, message, from_email, recipient_list, html_message, fail_silently ).start() From 2ca16e7ec059355b4b6aef703438adc1c66952f8 Mon Sep 17 00:00:00 2001 From: bluebamus Date: Mon, 10 Aug 2026 04:05:38 +0000 Subject: [PATCH 06/12] feat(sentry): drop scanner noise with a before_send filter Separate real failures from probe traffic so a recurring event is worth investigating. before_send discards Http404, DisallowedHost and SuspiciousOperation, and any event whose request URL is a .php, wp-admin/wp-content/wp-includes or dotfile probe. The URL pattern accepts ?, # and / as path terminators because it matches a full URL here, unlike the middleware which matches path_info. Wired into sentry_sdk.init() in both prod and stage. Co-Authored-By: Claude Opus 5 (1M context) --- config/settings/prod.py | 3 + config/settings/stage.py | 3 + config/settings/sub_settings/system/sentry.py | 44 +++++++++++++++ .../tests/test_sentry_filter.py | 55 +++++++++++++++++++ 4 files changed, 105 insertions(+) create mode 100644 config/settings/sub_settings/system/sentry.py create mode 100644 custom_middlewares/tests/test_sentry_filter.py diff --git a/config/settings/prod.py b/config/settings/prod.py index c6e921b..28e79ef 100644 --- a/config/settings/prod.py +++ b/config/settings/prod.py @@ -10,6 +10,7 @@ from .sub_settings.http.cors import * from .sub_settings.oauth.allauth_default import * from .sub_settings.system.logs import * +from .sub_settings.system.sentry import before_send from decouple import config @@ -170,6 +171,8 @@ # If you wish to associate users to errors (assuming you are using # django.contrib.auth) you may enable sending PII data. send_default_pii=True, + # 스캐너 노이즈를 걸러 실제 장애만 남긴다. + before_send=before_send, ) # Recaptcha Settings diff --git a/config/settings/stage.py b/config/settings/stage.py index 51104c4..a8a5707 100644 --- a/config/settings/stage.py +++ b/config/settings/stage.py @@ -8,6 +8,7 @@ from .sub_settings.http.cors import * from .sub_settings.oauth.allauth_default import * from .sub_settings.system.logs import * +from .sub_settings.system.sentry import before_send from decouple import config @@ -169,6 +170,8 @@ # If you wish to associate users to errors (assuming you are using # django.contrib.auth) you may enable sending PII data. send_default_pii=True, + # 스캐너 노이즈를 걸러 실제 장애만 남긴다. + before_send=before_send, ) # Recaptcha Settings diff --git a/config/settings/sub_settings/system/sentry.py b/config/settings/sub_settings/system/sentry.py new file mode 100644 index 0000000..c802ccf --- /dev/null +++ b/config/settings/sub_settings/system/sentry.py @@ -0,0 +1,44 @@ +"""Sentry 이벤트 필터. + +실제 장애와 스캐너 노이즈를 분리하기 위한 정책이다. +스캔 트래픽은 nginx와 BlockSuspiciousPathMiddleware가 이미 차단하고 있으므로, +그래도 Sentry까지 올라오는 이벤트는 잡음일 뿐 조사 가치가 없다. +""" + +import re + +from django.core.exceptions import DisallowedHost, SuspiciousOperation +from django.http import Http404 + +# Sentry로 보내지 않을 예외. +# - Http404: 존재하지 않는 URL 탐색. 애플리케이션 결함이 아니다. +# - DisallowedHost: 도메인이 아닌 IP 직결/랜덤 Host 헤더 스캔. +# - SuspiciousOperation: Django 보안 계층이 이미 차단한 요청. +IGNORED_EXCEPTIONS = (Http404, DisallowedHost, SuspiciousOperation) + +# 서비스와 무관한 탐색성 경로. 여기서 발생한 이벤트는 원인이 스캐너로 확정된다. +# 미들웨어와 달리 여기서는 path가 아니라 전체 URL을 검사하므로 query/fragment +# 구분자(?, #)도 경로 끝으로 인정해야 한다. +NOISY_PATH_PATTERN = re.compile( + r"(\.php([/?#]|$))" + r"|((^|/)(wp-admin|wp-content|wp-includes)([/?#]|$))" + r"|((^|/)\.(env|git|aws|ssh)([/?#]|$))", + re.IGNORECASE, +) + + +def is_noisy_path(event) -> bool: + url = (event.get("request") or {}).get("url") or "" + return bool(NOISY_PATH_PATTERN.search(url)) + + +def before_send(event, hint): + """Sentry 전송 직전 훅. None을 반환하면 이벤트를 버린다.""" + exc_info = hint.get("exc_info") + if exc_info and isinstance(exc_info[1], IGNORED_EXCEPTIONS): + return None + + if is_noisy_path(event): + return None + + return event diff --git a/custom_middlewares/tests/test_sentry_filter.py b/custom_middlewares/tests/test_sentry_filter.py new file mode 100644 index 0000000..07559e3 --- /dev/null +++ b/custom_middlewares/tests/test_sentry_filter.py @@ -0,0 +1,55 @@ +import pytest +from django.core.exceptions import DisallowedHost, SuspiciousOperation +from django.http import Http404 + +from config.settings.sub_settings.system.sentry import before_send + +pytestmark = pytest.mark.middlewares + + +def event(url="https://devspoon.com/portfolio/"): + return {"request": {"url": url}} + + +def hint(exception=None): + if exception is None: + return {} + return {"exc_info": (type(exception), exception, None)} + + +@pytest.mark.parametrize( + "exception", + [ + Http404("no such page"), + DisallowedHost("Invalid HTTP_HOST header"), + SuspiciousOperation("bad request"), + ], +) +def test_scanner_exceptions_are_dropped(exception): + assert before_send(event(), hint(exception)) is None + + +@pytest.mark.parametrize( + "url", + [ + "https://devspoon.com/wp.php", + "https://devspoon.com/bbs/board.php?bo_table=free", + "https://devspoon.com/site/phpinfo.php", + "https://devspoon.com/wp-admin/", + "https://devspoon.com/.env", + ], +) +def test_scanner_paths_are_dropped(url): + assert before_send(event(url), hint(ValueError("boom"))) is None + + +def test_real_errors_are_kept(): + payload = event() + + assert before_send(payload, hint(ValueError("boom"))) is payload + + +def test_event_without_request_is_kept(): + payload = {} + + assert before_send(payload, hint(ValueError("boom"))) is payload From 61a6353f2d9c3c432b2ec38959da82fa2e2c8737 Mon Sep 17 00:00:00 2001 From: bluebamus Date: Mon, 10 Aug 2026 04:05:38 +0000 Subject: [PATCH 07/12] docs: record the remediation results and deployment procedure Add reports/06-implementation-report.md and update the existing document set to reflect what was implemented, what was decided, and what is left as operations work. Records three things the planning documents could not confirm: - statistics migrations are generated under home/, not custom_middlewares/, because of Meta.app_label - with no migrations/ directory in the repo, a bare `makemigrations` reports "No changes detected" and creates nothing; the app labels must be named explicitly the first time - config/settings/prod.py fails `manage.py check` with staticfiles.E002, a pre-existing problem that matters when running the cleanup command Also documents the migration rehearsal used to verify that the schema change applies cleanly to a table that already holds duplicate rows. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 13 + docs/ai-docs/README.md | 7 + docs/ai-docs/handoff/05-agent-handoff.md | 108 +++++-- .../ai-docs/plans/04-remediation-work-plan.md | 25 ++ docs/ai-docs/reports/00-total-report.md | 41 ++- .../reports/06-implementation-report.md | 264 ++++++++++++++++++ .../ai-docs/reviews/01-sentry-error-review.md | 27 ++ .../security/02-nginx-php-scan-blocking.md | 9 + .../03-app-layer-access-guard-design.md | 29 ++ 9 files changed, 481 insertions(+), 42 deletions(-) create mode 100644 docs/ai-docs/reports/06-implementation-report.md diff --git a/README.md b/README.md index 2e74ed8..5f0b87d 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,19 @@ This project supports the development of personal homepages such as portfolios a - It is in the common folder of the custom_middlewares app. - There are two statistical functions: The first is "Connection Method Statistics", which collects the user's Windows, Mac, Android, etc., and the second is "Connection Hardware Statistics", which collects the user's Mobile, Tablet, PC, etc. You can check this information on the administrator page. +- Both statistics tables aggregate by the `stat_date` column, which carries a unique constraint so a day can only ever have one row. Paths listed in `STATS_EXCLUDED_PATH_PREFIXES` (admin, static, media, robots.txt, sitemap) are not counted. +- `access_guard.BlockSuspiciousPathMiddleware` returns an early 404 for scanner paths such as `*.php` and `/wp-admin/`, so they never reach the URL resolver or the statistics tables. It is a fallback for nginx, not a replacement. Tune it with `BLOCK_SUSPICIOUS_PATHS`, `SUSPICIOUS_PATH_RESPONSE_STATUS`, and `SUSPICIOUS_PATH_PATTERNS`. + +#### Maintenance command + +If the statistics tables already contain duplicate rows for the same day, merge them before relying on `stat_date`: + +```bash +python manage.py dedupe_connection_stats --dry-run # preview +python manage.py dedupe_connection_stats # sum duplicates, backfill stat_date +``` + +The command is idempotent. See `docs/ai-docs/reports/06-implementation-report.md` for the full deployment procedure. ### common diff --git a/docs/ai-docs/README.md b/docs/ai-docs/README.md index 73dae0d..0384c3b 100644 --- a/docs/ai-docs/README.md +++ b/docs/ai-docs/README.md @@ -2,6 +2,12 @@ 이 폴더는 `docs/error`의 Sentry 이슈를 Codex와 Claude가 함께 검토하고 후속 구현을 진행하기 위한 작업 문서 모음이다. +## 진행 상태 + +2026-08-10 기준 Phase 1~5 구현이 완료됐고 테스트는 115 passed / 1 skipped다. +Phase 0(nginx)과 SendGrid 계정 점검은 운영 작업으로 남아 있다. +구현 내용과 배포 절차는 [reports/06-implementation-report.md](reports/06-implementation-report.md)를 본다. + ## 문서 구조 - [reports/00-total-report.md](reports/00-total-report.md): 전체 요약, 우선순위, 승인/검토 포인트 @@ -10,6 +16,7 @@ - [security/03-app-layer-access-guard-design.md](security/03-app-layer-access-guard-design.md): Django 애플리케이션 2차 접근 차단 설계 - [plans/04-remediation-work-plan.md](plans/04-remediation-work-plan.md): 구현 작업 계획서 - [handoff/05-agent-handoff.md](handoff/05-agent-handoff.md): Codex/Claude 공용 핸드오프 체크리스트 +- [reports/06-implementation-report.md](reports/06-implementation-report.md): 구현 결과, 검증 기록, 운영 배포 절차 ## 기준 자료 diff --git a/docs/ai-docs/handoff/05-agent-handoff.md b/docs/ai-docs/handoff/05-agent-handoff.md index 7f76230..d58542a 100644 --- a/docs/ai-docs/handoff/05-agent-handoff.md +++ b/docs/ai-docs/handoff/05-agent-handoff.md @@ -1,17 +1,22 @@ # Agent Handoff +갱신일: 2026-08-10 + ## 현재 상태 - `docs/error`에 Sentry export 5개가 있다. -- `docs/error`는 현재 git 기준 미추적 상태로 보인다. -- 이 문서 세트는 `docs/ai-docs`에 새로 작성되었다. -- 코드 변경은 아직 하지 않았다. +- `docs/error`는 현재 git 기준 미추적 상태로 보인다. 이번 작업에서 바꾸지 않았다. +- 계획서 Phase 1~5의 코드 작업이 끝났다. 테스트는 115 passed / 1 skipped다. +- 남은 것은 운영 작업이다: nginx 차단 적용, 운영 DB 정리 실행, SendGrid 계정 점검. +- 결과 정리는 [../reports/06-implementation-report.md](../reports/06-implementation-report.md)에 있다. ## 주요 코드 위치 +기존 위치: + - 통계 미들웨어: `custom_middlewares/middlewares/statistics.py` - 통계 모델: `custom_middlewares/models.py` -- 통계 admin 등록: `home/admin/default_admin.py` +- 통계 admin 등록: `home/admin/default_admin.py`, `custom_middlewares/admin/home_statistics_admin.py` - 문의 모델: `portfolio/models.py` - 문의 뷰: `portfolio/views.py` - 문의 템플릿: `templates/portfolio/portfolio.html` @@ -19,38 +24,85 @@ - 공통 middleware 설정: `config/settings/base.py` - 운영 `ALLOWED_HOSTS`: `config/settings/prod.py` -## 다음 에이전트가 바로 확인할 것 +이번 작업에서 추가된 위치: + +- 탐색성 경로 차단: `custom_middlewares/middlewares/access_guard.py` +- 통계 정리 명령: `custom_middlewares/management/commands/dedupe_connection_stats.py` +- 문의 폼: `portfolio/forms.py` +- 전화번호 validator: `portfolio/validators.py` +- Sentry 이벤트 필터: `config/settings/sub_settings/system/sentry.py` +- 테스트: `custom_middlewares/tests/`, `portfolio/tests/` + +## 확인 완료된 사항 + +핸드오프 시점에 미확인이던 항목들의 결과다. + +1. 운영 nginx 설정 파일은 이 저장소에 없다. 여전히 배포 환경에서 별도로 작업해야 한다. +2. `migrations/`는 `.gitignore` 대상이라 저장소에 마이그레이션이 없다. + 그래서 중복 정리는 data migration이 아니라 management command로 만들었다. +3. `custom_middlewares.models`의 통계 모델은 `Meta.app_label = "home"`이라 + 마이그레이션이 **`home/migrations/`**에 생성된다. `custom_middlewares`에는 생기지 않는다. + `makemigrations --dry-run -v 2`와 실제 마이그레이션 리허설로 확인했다. +4. `GetInTouchView.post()`의 도달 불가능한 dead code 6블록을 제거했다. +5. `utils/email/async_send_email.py`에 결과를 반환하는 `send_mail_sync()`를 추가했고, + 문의 폼은 이 함수를 쓴다. 기존 스레드 방식 `send_mail()`은 `verify_email_mixins.py`용으로 유지된다. + +새로 확인한 것: + +6. 저장소에 `migrations/` 디렉터리가 없는 상태에서는 인자 없는 `makemigrations`가 + 프로젝트 앱에 아무것도 만들지 않는다("No changes detected"). 최초 1회는 앱 이름을 명시해야 한다. -1. 운영 nginx 설정 파일은 이 저장소에 없다. 배포 환경에서 별도로 확인해야 한다. -2. Django 마이그레이션 디렉터리가 현재 검색에서 보이지 않았다. 실제 migration 정책을 먼저 확인한다. -3. `custom_middlewares.models`의 통계 모델은 `Meta.app_label = "home"`을 사용한다. migration 생성 전 Django app label과 migration module을 반드시 검증한다. -4. `GetInTouchView.post()`에는 첫 번째 return 이후 dead code가 여러 번 반복된다. 기능 수정 시 함께 정리한다. -5. `utils/email/async_send_email.py`는 호출자에게 발송 결과를 반환하지 않는다. 메일 실패 처리 설계 시 이 제약을 반영한다. +```bash +python manage.py makemigrations users blog board home portfolio custom_middlewares +``` + +7. `common/error/error_views.py`의 400/403/404/500 핸들러가 모두 HTTP 200을 반환하고 있었다. 함께 수정했다. +8. `config/settings/prod.py`는 `manage.py check`에서 `staticfiles.E002`로 실패한다. 기존 문제이고 CD는 `stage` 설정을 쓴다. + +## 결정된 항목 -## 구현 순서 권장 +핸드오프의 "열려 있는 결정"에 대한 답이다. -1. nginx 1차 차단 적용 -2. Django 2차 차단 미들웨어 추가 -3. 통계 중복 데이터 정리 -4. 통계 모델/미들웨어 구조 개선 -5. 문의 폼 검증 개선 -6. 메일 실패 처리 개선 -7. Sentry 필터와 테스트 보강 +| 항목 | 결정 | +| --- | --- | +| nginx 차단 응답 | `444` 유지. 앱 계층은 `404` | +| 전화번호 입력 정책 | 선택 입력, 값이 있으면 국내 휴대폰 형식만 허용 | +| 통계의 봇 처리 | 현행 유지 (하드웨어 통계에 `bot` 컬럼이 이미 있다) | +| `GetInTouchLog.state` 의미 | 문의 접수 성공 여부. 메일 결과는 신규 `status` | +| 메일 비동기 처리 | 문의 폼은 동기 발송으로 전환. Celery 이관은 후속 과제 | -## 열려 있는 결정 +## 새로 추가된 설정값 -- nginx 차단 응답: `444` 유지 또는 `404`/`403` 대체 -- 전화번호 입력 정책: 국내 휴대폰만 허용 또는 국제 번호 허용 -- 통계의 봇 처리: 제외, 별도 bot 카운트, 현행 유지 -- `GetInTouchLog.state` 의미: 접수 성공인지 메일 발송 성공인지 -- 메일 비동기 처리: 현행 스레드 유지, Celery 전환, 동기 발송 선택지 추가 +`config/settings/base.py`에 있다. -## 검증 명령 후보 +| 설정 | 기본값 | 용도 | +| --- | --- | --- | +| `BLOCK_SUSPICIOUS_PATHS` | `True` | 탐색성 경로 차단 on/off | +| `SUSPICIOUS_PATH_RESPONSE_STATUS` | `404` | 차단 응답 status | +| `SUSPICIOUS_PATH_PATTERNS` | 기본 패턴 목록 | 차단 정규식 | +| `STATS_EXCLUDED_PATH_PREFIXES` | admin/static/media/silk/robots/sitemap | 통계 제외 경로 | +| `EMAIL_DNS_VALIDATION` | `True` (test는 `False`) | 문의 폼 이메일 DNS 검증 | +| `EMAIL_DNS_VALIDATION_TIMEOUT` | `10` | DNS 조회 타임아웃(초) | + +## 검증 명령 ```bash python manage.py check --settings=config.settings.dev -python manage.py test home.tests.test_error --settings=config.settings.test -python manage.py test portfolio --settings=config.settings.test +pytest +pytest -m middlewares +pytest -m portfolio ``` -환경변수와 DB 의존성이 있으므로 로컬에서 바로 실행되지 않을 수 있다. 실패 시 누락된 환경변수와 DB 연결 설정을 먼저 확인한다. +로컬 실행에는 `.env`와 Redis가 필요하다. +`config/settings/test.py`는 import 시점에 `get_redis_connection()`을 호출하므로 Redis가 떠 있어야 한다. +필요한 환경변수 목록은 `.github/workflows/testing.yml`의 "Add environment variables to .env" 단계를 참고한다. + +DB는 sqlite를 쓰고 `pytest.ini`의 `--no-migrations`로 모델에서 직접 테이블을 만든다. + +## 다음 담당자가 할 일 + +1. nginx 차단 location 적용 ([../security/02-nginx-php-scan-blocking.md](../security/02-nginx-php-scan-blocking.md)) +2. 운영 DB 백업 후 마이그레이션과 `dedupe_connection_stats` 실행 + ([../reports/06-implementation-report.md](../reports/06-implementation-report.md)의 "운영 배포 절차") +3. SendGrid API key / sender 인증 / 크레딧 점검 +4. 배포 후 Sentry에서 `MultipleObjectsReturned`, `DataError`, PHP 스캔 이벤트가 사라졌는지 확인 diff --git a/docs/ai-docs/plans/04-remediation-work-plan.md b/docs/ai-docs/plans/04-remediation-work-plan.md index 28e29cf..e08c997 100644 --- a/docs/ai-docs/plans/04-remediation-work-plan.md +++ b/docs/ai-docs/plans/04-remediation-work-plan.md @@ -1,7 +1,22 @@ # Remediation Work Plan +갱신일: 2026-08-10 + +| Phase | 내용 | 상태 | +| --- | --- | --- | +| 0 | nginx 1차 차단 | 미적용 (운영 작업) | +| 1 | Django 2차 차단 미들웨어 | 완료 | +| 2 | 통계 중복 정리 및 모델 개선 | 코드 완료, 운영 DB 실행 대기 | +| 3 | 문의 폼 검증 개선 | 완료 | +| 4 | 메일 발송 실패 처리 | 코드 완료, SendGrid 계정 점검 대기 | +| 5 | 관측 및 회귀 방지 | 완료 | + +구현 상세와 배포 절차는 [../reports/06-implementation-report.md](../reports/06-implementation-report.md)에 있다. + ## Phase 0: 운영 차단 +상태: **미적용 (운영 작업)** + 담당 후보: 운영자 또는 배포 담당 에이전트 1. nginx 설정에 [../security/02-nginx-php-scan-blocking.md](../security/02-nginx-php-scan-blocking.md)의 PHP/WordPress 차단 location을 추가한다. @@ -16,6 +31,8 @@ ## Phase 1: Django 2차 차단 미들웨어 +상태: **완료** + 담당 후보: Codex 1. `custom_middlewares/middlewares/access_guard.py`를 추가한다. @@ -38,6 +55,8 @@ ## Phase 2: 통계 테이블 중복 정리 및 모델 개선 +상태: **코드 완료. 운영 DB에서 `dedupe_connection_stats` 실행이 남아 있다.** + 담당 후보: Claude 또는 Codex 1. 운영 DB에서 중복 현황을 조회한다. @@ -78,6 +97,8 @@ stat_date = models.DateField(unique=True, db_index=True) ## Phase 3: 문의 폼 검증 개선 +상태: **완료** + 담당 후보: Codex 1. `portfolio/forms.py`를 추가하거나 기존 패턴에 맞는 위치에 `GetInTouchForm`을 만든다. @@ -104,6 +125,8 @@ stat_date = models.DateField(unique=True, db_index=True) ## Phase 4: 메일 발송 실패 처리 +상태: **코드 완료. 운영 SendGrid 계정 점검이 남아 있다.** + 담당 후보: Claude 또는 Codex 1. 운영 SendGrid 상태를 확인한다. @@ -127,6 +150,8 @@ stat_date = models.DateField(unique=True, db_index=True) ## Phase 5: 관측 및 회귀 방지 +상태: **완료** + 1. Sentry ignore/filter 정책을 정리한다. 2. PHP scan 차단량을 nginx access log 또는 별도 metric으로 집계할지 결정한다. 3. 통계 미들웨어 테스트를 CI에 포함한다. diff --git a/docs/ai-docs/reports/00-total-report.md b/docs/ai-docs/reports/00-total-report.md index 1fcaf75..b7ed8ba 100644 --- a/docs/ai-docs/reports/00-total-report.md +++ b/docs/ai-docs/reports/00-total-report.md @@ -1,6 +1,10 @@ # Total Report 작성일: 2026-08-09 +갱신일: 2026-08-10 (구현 완료 반영) + +> 구현 결과와 배포 절차는 [06-implementation-report.md](06-implementation-report.md)에 있다. +> 이 문서는 조사 시점의 분석을 유지하고, 각 항목의 처리 상태만 덧붙였다. ## 결론 @@ -14,14 +18,18 @@ ## 우선순위 -| 우선순위 | 작업 | 이유 | -| --- | --- | --- | -| P0 | nginx PHP/WordPress 스캔 차단 | 애플리케이션 비용과 Sentry 노이즈를 즉시 줄인다. | -| P0 | 통계 테이블 중복 데이터 정리 | 현재 중복 row가 존재하면 매 요청마다 `MultipleObjectsReturned`가 재발한다. | -| P1 | 통계 모델을 일자 단위 유니크 구조로 변경 | 중복 재발을 구조적으로 막는다. | -| P1 | 문의 폼 입력 검증 및 로그 저장 순서 개선 | `DataError`와 가짜 성공 로그를 막는다. | -| P1 | SendGrid 크레딧/키 상태 점검 및 메일 실패 처리 개선 | 외부 서비스 실패를 사용자 성공 처리와 분리한다. | -| P2 | Sentry 필터링/태그 정리 | 악성 스캔과 실제 장애를 분리해 관측 품질을 높인다. | +| 우선순위 | 작업 | 이유 | 상태 | +| --- | --- | --- | --- | +| P0 | nginx PHP/WordPress 스캔 차단 | 애플리케이션 비용과 Sentry 노이즈를 즉시 줄인다. | 운영 작업 대기 | +| P0 | 통계 테이블 중복 데이터 정리 | 현재 중복 row가 존재하면 매 요청마다 `MultipleObjectsReturned`가 재발한다. | 도구 완료, 운영 실행 대기 | +| P1 | 통계 모델을 일자 단위 유니크 구조로 변경 | 중복 재발을 구조적으로 막는다. | 완료 | +| P1 | 문의 폼 입력 검증 및 로그 저장 순서 개선 | `DataError`와 가짜 성공 로그를 막는다. | 완료 | +| P1 | SendGrid 크레딧/키 상태 점검 및 메일 실패 처리 개선 | 외부 서비스 실패를 사용자 성공 처리와 분리한다. | 코드 완료, 계정 점검 대기 | +| P2 | Sentry 필터링/태그 정리 | 악성 스캔과 실제 장애를 분리해 관측 품질을 높인다. | 완료 | + +추가로, 조사 범위 밖에서 발견해 함께 고친 문제가 하나 있다. +`common/error/error_views.py`의 400/403/404/500 핸들러가 모두 HTTP 200을 반환하고 있었다. +자세한 내용은 [06-implementation-report.md](06-implementation-report.md)의 "계획 외 추가 수정"에 있다. ## 확인된 이슈별 요약 @@ -86,13 +94,18 @@ - 비동기 스레드 대신 Celery 작업 또는 동기 결과 반환 래퍼로 실패 상태를 추적한다. - 실패 이벤트에는 recipient 원문을 마스킹하고, 사용자에게는 접수/발송 상태를 분리해 안내한다. -## 승인 필요 사항 +## 승인 필요 사항 (2026-08-10 결정 완료) -- nginx 설정에 `return 444` 정책을 적용할지, 또는 운영 표준상 `403`/`404`로 대체할지 결정해야 한다. -- 통계 테이블 중복 row 정리 시 날짜별 카운트를 합산할지, 최신 row만 보존할지 결정해야 한다. 권장은 합산이다. -- 문의 폼 전화번호 정책을 국내 휴대폰 형식만 허용할지, 국제 전화번호까지 허용할지 결정해야 한다. -- 메일 실패 시 사용자 메시지를 "접수 완료, 발송 지연"으로 바꿀지, 실패로 명확히 안내할지 결정해야 한다. +- nginx 차단 응답: `444` 유지. 앱 계층 2차 차단은 `404`로 하고 `SUSPICIOUS_PATH_RESPONSE_STATUS`로 조정 가능하게 했다. +- 통계 중복 row 정리: 날짜별 카운트를 **합산**한다. 최신 row만 보존하지 않는다. +- 문의 폼 전화번호: 선택 입력으로 두되, 값이 있으면 **국내 휴대폰 형식만** 허용한다. +- 메일 실패 시 사용자 메시지: **"접수 완료, 발송 지연"**으로 안내한다. 문의 자체는 항상 저장된다. ## 다음 작업 -구현자는 [../plans/04-remediation-work-plan.md](../plans/04-remediation-work-plan.md)의 단계 순서대로 진행한다. nginx 반영은 앱 배포와 별도 운영 작업이므로, 적용 후 access log와 Sentry 발생량을 함께 확인한다. +코드 작업은 [06-implementation-report.md](06-implementation-report.md) 기준으로 완료됐다. +남은 것은 운영 작업이다. + +1. nginx 차단 location 적용 후 access log와 Sentry 발생량 확인 +2. 운영 DB 백업 → `makemigrations` / `migrate` → `dedupe_connection_stats` 실행 +3. SendGrid API key, sender/domain 인증, 크레딧 한도 점검 diff --git a/docs/ai-docs/reports/06-implementation-report.md b/docs/ai-docs/reports/06-implementation-report.md new file mode 100644 index 0000000..00d2b1a --- /dev/null +++ b/docs/ai-docs/reports/06-implementation-report.md @@ -0,0 +1,264 @@ +# Implementation Report + +작성일: 2026-08-10 +브랜치: `docs/sentry-error-remediation-plan` +기준 문서: [../plans/04-remediation-work-plan.md](../plans/04-remediation-work-plan.md) + +## 요약 + +[04-remediation-work-plan.md](../plans/04-remediation-work-plan.md)의 Phase 1~5를 코드로 구현했다. +Phase 0(nginx)은 이 저장소에 설정 파일이 없어 운영 작업으로 남는다. + +테스트는 `pytest` 기준 **115 passed, 1 skipped**이다. +작업 시작 시점의 기준선은 9 passed, 1 skipped였으므로 106건이 새로 추가되었다. +skip 1건은 이 작업과 무관한 기존 항목(`home.tests.test_home_html.test_home`)이다. + +## 확정된 결정 + +계획서 "열려 있는 결정"과 "승인 필요 사항"에 대한 답이다. + +| 항목 | 결정 | +| --- | --- | +| 앱 계층 차단 응답 | `404`. `SUSPICIOUS_PATH_RESPONSE_STATUS`로 변경 가능 | +| nginx 차단 응답 | `444` 유지 (운영 적용 시 판단) | +| 통계 중복 정리 방식 | 날짜별 **합산**. 최신 row만 보존하지 않는다 | +| 통계 마이그레이션 | migration 파일 대신 management command로 정리 | +| 전화번호 정책 | 선택 입력, 값이 있으면 국내 휴대폰 형식만 허용 | +| 메일 비동기 처리 | 문의 폼은 **동기 발송**으로 전환, 결과를 상태로 기록 | +| `GetInTouchLog.state` 의미 | 접수 성공 여부로 확정. 발송 결과는 신규 `status`로 분리 | +| 통계의 봇 처리 | 현행 유지. 하드웨어 통계에 이미 `bot` 컬럼이 있다 | + +## Phase별 구현 내용 + +### Phase 0: 운영 차단 (미완료 — 운영 작업) + +이 저장소에는 nginx 설정 파일이 없다. +[../security/02-nginx-php-scan-blocking.md](../security/02-nginx-php-scan-blocking.md)의 location 블록을 운영 서버에 직접 적용해야 한다. +앱 계층 차단(Phase 1)이 이미 동작하므로 서비스 안전성 문제는 없고, 남은 이득은 gunicorn까지 도달하는 트래픽량 감소다. + +### Phase 1: Django 2차 차단 미들웨어 (완료) + +신규 `custom_middlewares/middlewares/access_guard.py`의 `BlockSuspiciousPathMiddleware`. + +- `config/settings/base.py`의 `MIDDLEWARE`에서 `SecurityMiddleware` 바로 뒤, 통계 미들웨어보다 앞에 위치한다. +- 차단 시 본문 없는 `404`를 조기 반환하므로 URL resolver와 통계 DB write 경로를 타지 않는다. +- 설정값: `BLOCK_SUSPICIOUS_PATHS`, `SUSPICIOUS_PATH_RESPONSE_STATUS`, `SUSPICIOUS_PATH_PATTERNS`. +- 설계 문서 대비 추가한 패턴: `.env`, `.git`, `.aws`, `.ssh` 유출 스캔. + +### Phase 2: 통계 중복 정리 및 모델 개선 (완료) + +`custom_middlewares/models.py`: + +- `DailyStatsMixin`을 도입하고 `stat_date = DateField(unique=True)`를 집계키로 추가했다. +- `ordering`을 `-created_at`에서 `-stat_date`로 바꿨다. +- 각 모델에 `COUNTER_FIELDS`를 정의해 정리 스크립트가 카운터 컬럼을 알 수 있게 했다. + +`custom_middlewares/middlewares/statistics.py`: + +- `get_or_create(created_at__date=today)`를 버리고 `increment_daily_counter()`로 교체했다. + UPDATE → (없으면) INSERT → (경합 시) UPDATE 순서로 처리한다. + UPDATE 한 문장이 원자적이라 `select_for_update` 잠금이 필요 없다. +- 동시 생성 경합은 유니크 제약과 `IntegrityError` 재시도로 처리한다. +- admin 판별을 `"admin" not in request.path`에서 `path_info.startswith()` 기반 prefix 목록으로 바꿨다. + 설정값은 `STATS_EXCLUDED_PATH_PREFIXES`이고 static/media/silk/robots/sitemap을 포함한다. +- 통계 DB 오류가 사용자 요청 실패로 번지지 않도록 `DatabaseError`를 잡아 경고 로그만 남긴다. + +`custom_middlewares/management/commands/dedupe_connection_stats.py`: + +- 날짜별 중복 row의 카운트를 합산해 1개로 병합하고 `stat_date`를 backfill한다. +- `--dry-run`을 지원하고, 여러 번 실행해도 결과가 같다. + +`custom_middlewares/admin/home_statistics_admin.py`: + +- `created_at__day=...`은 '일(day of month)'만 비교해 다른 달 row까지 섞였다. `stat_date=오늘`로 교체했다. + +#### `stat_date`를 nullable로 둔 이유 + +이 프로젝트는 `.gitignore`에 `migrations/`가 있어 migration 파일을 저장소에 두지 않는다. +기존 운영 테이블에 not-null unique 컬럼을 한 번에 추가하면 모든 기존 row가 같은 기본값을 받아 유니크 제약에 걸린다. +nullable로 추가하면 기존 row는 전부 NULL이 되고(유니크 제약은 NULL을 비교하지 않는다), +이어서 `dedupe_connection_stats`가 합산과 backfill을 수행한다. 마이그레이션 1회로 끝난다. + +계획서는 `unique=True, db_index=True`를 제시했으나 `db_index`는 생략했다. +Django는 `unique=True`일 때 별도 인덱스를 만들지 않으므로 중복 지정이다. + +### Phase 3: 문의 폼 검증 개선 (완료) + +신규 `portfolio/forms.py`의 `GetInTouchForm`, 신규 `portfolio/validators.py`. + +| 필드 | 정책 | +| --- | --- | +| `name` | 필수, trim, 최대 300자 | +| `emailfrom` | 필수, `EmailField`, 최대 128자, `test` 포함 도메인 거부, DNS MX 검증 | +| `number` | 선택, 최대 16자, 값이 있으면 국내 휴대폰 형식 | +| `subject` | 필수, trim, 최대 300자 | +| `message` | 필수, trim, 최대 5000자 | + +- `GetInTouchView.post()`를 form 기반으로 재작성했다. 첫 `return` 뒤에 6번 반복되던 도달 불가능한 저장 코드를 제거했다(뷰 172줄 → 66줄). +- `check_email_validation_with_dns()`의 `is_valid` 미초기화 문제는 로직을 `GetInTouchForm.clean_emailfrom()`으로 옮기며 해소했다. +- DNS 예외를 두 갈래로 나눴다. `DNSTimeoutError`/`DNSConfigurationError`/`NoNameserverError`는 '검증 불가'로 보고 통과시키고, + `NoMXError` 등 나머지는 '잘못된 주소'로 보고 거부한다. 이전 구현은 DNS 타임아웃도 잘못된 주소로 처리했다. +- `EMAIL_DNS_VALIDATION` 설정으로 DNS 검증을 끌 수 있고, 테스트 환경에서는 꺼 둔다. +- 수신자는 hidden input `emailto`가 아니라 `settings.DEFAULT_FROM_EMAIL`로 고정된다는 점을 테스트로 고정했다. +- `templates/portfolio/portfolio.html`의 입력 필드에 서버 정책과 같은 `maxlength`와 `required`를 추가했다. + +captcha 적용은 하지 않았다. `INSTALLED_APPS`에 `captcha`가 있으나 폼 검증만으로 이번 Sentry 이슈는 재발하지 않고, +captcha 도입은 사용자 경험 변경이라 별도 결정이 필요하다. + +### Phase 4: 메일 발송 실패 처리 (완료) + +`utils/email/async_send_email.py`: + +- `send_mail_sync()`를 추가했다. 동기로 발송하고 성공 여부를 `bool`로 반환한다. +- 기존 스레드 방식 `send_mail()`은 그대로 두되 내부에서 `send_mail_sync()`를 호출하도록 정리했다. + `utils/email/verify_email_mixins.py`가 계속 사용한다. +- `mask_email()` / `mask_recipients()`로 로그와 Sentry에서 수신자 주소를 마스킹한다. (`hong.gildong@gmail.com` → `h***g@gmail.com`) + +`portfolio/models.py`: + +- `GetInTouchLog.status`(`received`/`queued`/`sent`/`failed`)를 추가했다. +- `state`는 '문의 접수 성공 여부'로 의미를 확정했고, 메일 발송 결과는 `status`가 담는다. +- `phone_number`의 인라인 정규식을 `portfolio/validators.py`의 공용 validator로 교체했다. + 기존 정규식 `[0|1|6|7|8|9]`은 문자 클래스에 `|`가 그대로 들어가 있어 `01|-1234-5678` 같은 입력을 통과시켰다. + +`portfolio/views.py`: + +- 문의를 **먼저 저장**하고 그 뒤에 메일을 보낸다. 메일 벤더 장애로 문의가 유실되지 않는다. +- 발송 결과에 따라 `status`를 `sent`/`failed`로 갱신한다. +- 발송 실패 시 성공 메시지 대신 "접수 완료, 발송 지연" 경고 메시지를 보여준다. + +`portfolio/admin.py`의 `GetInTouchLogAdmin`에 `status`를 list_display와 list_filter에 추가했다. + +**SendGrid 계정 상태 점검은 코드 작업이 아니다.** API key 유효성, sender/domain 인증, 크레딧 한도는 운영자가 직접 확인해야 한다. + +### Phase 5: 관측 및 회귀 방지 (완료) + +신규 `config/settings/sub_settings/system/sentry.py`: + +- `before_send` 훅으로 스캐너 노이즈를 버린다. +- 버리는 대상: `Http404`, `DisallowedHost`, `SuspiciousOperation` 예외와 `.php`/`wp-*`/`.env` 계열 URL에서 난 이벤트. +- `config/settings/prod.py`와 `config/settings/stage.py`의 `sentry_sdk.init()`에 연결했다. + +차단량 집계는 별도 metric을 두지 않고 nginx access log와 미들웨어의 INFO 로그(`blocked suspicious path`)로 확인한다. + +테스트는 `pytest.ini`에 `portfolio`, `middlewares` 마커를 추가해 CI에서 자동 수집된다. + +## 계획 외 추가 수정 + +### 커스텀 에러 페이지가 HTTP 200을 반환하던 문제 + +`common/error/error_views.py`의 400/403/404/500 핸들러가 모두 다음 형태였다. + +```python +response = HttpResponse() +response.status_code = 404 # 이 응답은 버려진다 +return render(request, "errors/error.html", context=context) # 200으로 나간다 +``` + +`render()`가 새 응답을 만들기 때문에 위에서 설정한 status_code는 사용되지 않았다. +결과적으로 존재하지 않는 URL이 **HTTP 200**으로 응답했다. 스캐너에게는 모든 경로가 유효해 보이고, 검색엔진과 모니터링도 오탐한다. +`render(..., status=)`로 수정했다. + +기존 테스트 `home.tests.test_error.test_error_404`는 본문의 "404 Error" 문자열만 검사해 이 문제를 잡지 못했다. + +## 검증 + +### 테스트 + +``` +115 passed, 1 skipped +``` + +새로 추가한 테스트 파일: + +| 파일 | 대상 | +| --- | --- | +| `custom_middlewares/tests/test_access_guard.py` | 탐색성 경로 차단, 통계 미접촉, 설정 토글 | +| `custom_middlewares/tests/test_statistics.py` | 집계 원자성, 유니크 제약, 경합 복구, 제외 경로 | +| `custom_middlewares/tests/test_dedupe_command.py` | 합산, 날짜 분리, backfill, 멱등성, dry-run | +| `custom_middlewares/tests/test_sentry_filter.py` | 노이즈 이벤트 폐기, 실제 오류 보존 | +| `portfolio/tests/test_get_in_touch_form.py` | 입력 정책, 전화번호 길이/형식, DNS 예외 분기 | +| `portfolio/tests/test_get_in_touch_view.py` | 접수/발송 분리, DataError 재발 방지, 수신자 고정 | +| `portfolio/tests/test_email_sending.py` | 발송 결과 반환, PII 마스킹 | + +기존 `custom_middlewares/tests.py`와 `portfolio/tests.py`는 내용 없는 스텁이라 같은 이름의 패키지로 대체했다. + +### 마이그레이션 리허설 + +실제 운영 업그레이드 순서를 sqlite로 재현해 검증했다. + +1. 변경 전(HEAD) 모델로 스키마를 만들고 +2. 같은 날짜 중복 row를 심고 (`2026-08-05` 2건, `2026-08-06` 3건) +3. 신규 코드로 `makemigrations` / `migrate`를 적용한 뒤 +4. `dedupe_connection_stats`를 실행했다. + +결과: + +- 마이그레이션이 중복 row가 있는 상태에서 오류 없이 적용됐다. +- 날짜별 1 row로 병합됐고 카운트가 정확히 합산됐다. (`2026-08-05`: win 10+7 = 17) +- DB 레벨 유니크 제약이 실제로 동작한다. (중복 INSERT 시 `UNIQUE constraint failed`) +- 두 번째 실행은 아무것도 바꾸지 않았다. + +### 확인된 마이그레이션 동작 + +핸드오프 문서의 미확인 항목에 대한 답이다. + +- 통계 모델은 `Meta.app_label = "home"` 때문에 마이그레이션이 **`home/migrations/`**에 생성된다. `custom_middlewares`에는 마이그레이션이 생기지 않는다. +- 저장소에 `migrations/` 디렉터리가 없는 상태에서 `python manage.py makemigrations`를 인자 없이 실행하면 **"No changes detected"만 나오고 아무것도 만들지 않는다.** + Django는 migrations 패키지가 없는 앱을 인자 없는 makemigrations 대상에서 제외한다. 최초 1회는 앱 이름을 명시해야 한다. + +```bash +python manage.py makemigrations users blog board home portfolio custom_middlewares +``` + + `.github/workflows/testing.yml`의 `python manage.py makemigrations`도 같은 이유로 프로젝트 앱에는 사실상 아무 동작을 하지 않는다. + 테스트는 `pytest.ini`의 `--no-migrations` 덕분에 모델에서 직접 테이블을 만들어 통과한다. + +## 운영 배포 절차 + +순서를 지켜야 한다. + +```bash +# 0. 운영 DB 백업 +# 1. nginx 차단 적용 (security/02 문서) +nginx -t && nginx -s reload + +# 2. 코드 배포 후 마이그레이션 생성/적용 +python manage.py makemigrations home portfolio --settings=config.settings.prod +python manage.py migrate --settings=config.settings.prod + +# 3. 중복 현황 확인 +python manage.py dedupe_connection_stats --dry-run --settings=config.settings.prod + +# 4. 정리 실행 +python manage.py dedupe_connection_stats --settings=config.settings.prod + +# 5. 확인 +# - admin 통계 화면이 정상 동작하는지 +# - Sentry에 MultipleObjectsReturned가 더 오지 않는지 +# - gunicorn access log에 .php 요청이 사라졌는지 +``` + +`config/settings/prod.py`는 `manage.py check`에서 `staticfiles.E002` +(`STATICFILES_DIRS`가 `STATIC_ROOT`를 포함) 오류가 난다. +이 작업과 무관한 기존 문제이고 CD 파이프라인은 `config.settings.stage`를 쓴다. +prod 설정으로 위 명령을 실행해야 한다면 `--skip-checks`를 붙이거나 static 설정을 먼저 정리한다. + +## 남은 작업 + +| 항목 | 담당 | 비고 | +| --- | --- | --- | +| nginx 차단 location 적용 | 운영자 | Phase 0 | +| SendGrid API key / sender 인증 / 크레딧 점검 | 운영자 | Phase 4의 근본 원인 | +| 운영 DB 백업 후 dedupe 실행 | 운영자 | 위 배포 절차 | +| `staticfiles.E002` 정리 | 별도 이슈 | 기존 문제 | +| 문의 폼 captcha 적용 여부 | 결정 필요 | 스팸이 계속되면 검토 | +| 메일 발송의 Celery 이관 | 후속 | 아래 참고 | + +### 동기 발송의 트레이드오프 + +문의 폼 메일을 동기로 보내므로 발송 결과를 정확히 알 수 있는 대신, 메일 벤더 응답이 느리면 그만큼 요청이 길어진다. +SendGrid 백엔드는 Django의 `EMAIL_TIMEOUT`을 따르지 않으므로 타임아웃을 앱에서 강제할 수 없다. + +문의량이 늘거나 응답 지연이 문제가 되면 Celery task로 이관한다. +`GetInTouchLog.status`에 `queued`를 이미 정의해 두었으므로, 접수 시 `queued`로 저장하고 task 완료 시 `sent`/`failed`로 갱신하면 된다. diff --git a/docs/ai-docs/reviews/01-sentry-error-review.md b/docs/ai-docs/reviews/01-sentry-error-review.md index 6486654..3e2ffd7 100644 --- a/docs/ai-docs/reviews/01-sentry-error-review.md +++ b/docs/ai-docs/reviews/01-sentry-error-review.md @@ -1,5 +1,10 @@ # Sentry Error Review +갱신일: 2026-08-10 + +> 이 문서는 조사 시점의 원인 분석이다. 각 이슈의 처리 결과는 문서 끝의 "처리 결과"와 +> [../reports/06-implementation-report.md](../reports/06-implementation-report.md)에 있다. + ## 조사 범위 검토 대상은 `docs/error`의 5개 Sentry export 문서와 현재 Django 코드다. @@ -137,3 +142,25 @@ EmailThread(...).start() - 현재 `docs/error` 자체가 git 미추적 상태다. 문서화 및 구현 전 커밋 범위를 명확히 해야 한다. - 운영 DB에 이미 중복 데이터가 있으므로 코드만 수정하면 마이그레이션이 실패하거나 중복 문제가 남을 수 있다. - nginx 차단 없이 앱 코드만 수정하면 불필요한 요청량과 Sentry 노이즈가 계속 발생한다. + +## 처리 결과 (2026-08-10) + +| 이슈 | 처리 | 근거 | +| --- | --- | --- | +| PYTHON-DJANGO-7H `ConnectionHardwareStats.MultipleObjectsReturned` | 해결 | `stat_date` 유니크 제약 + `increment_daily_counter()` | +| PYTHON-DJANGO-7G `ConnectionMethodStats.MultipleObjectsReturned` | 해결 | 위와 동일 | +| PYTHON-DJANGO-7B `DataError value too long for varchar(16)` | 해결 | `GetInTouchForm`이 저장 전에 길이/형식을 거부 | +| PYTHON-DJANGO-7C / 7D `Email 401 Unauthorized` | 코드 측면 해결 | 발송 결과를 `status`로 기록하고 사용자 안내를 분리. 계정/크레딧 점검은 운영 작업 | + +원인 분석 중 다음 두 가지는 실제 구현에서 수정됐다. + +- `check_email_validation_with_dns()`의 `is_valid` 미초기화: 로직을 `GetInTouchForm.clean_emailfrom()`으로 옮기며 해소했다. + 더불어 DNS 타임아웃/네임서버 오류는 '검증 불가'로 보고 통과시키도록 분기를 나눴다. 이전 구현은 이를 '잘못된 주소'로 처리했다. +- `post()` 하단의 도달 불가능한 중복 저장 코드 6블록: 전부 제거했다. + +`docs/error`의 git 추적 여부는 이번 작업에서 바꾸지 않았다. + +분석 당시에는 확인하지 못했던 사실 두 가지도 기록해 둔다. + +- 통계 모델의 마이그레이션은 `Meta.app_label = "home"` 때문에 `home/migrations/`에 생성된다. +- 저장소에 `migrations/` 디렉터리가 없으면 인자 없는 `makemigrations`는 프로젝트 앱에 아무것도 만들지 않는다. 최초 1회는 앱 이름을 명시해야 한다. diff --git a/docs/ai-docs/security/02-nginx-php-scan-blocking.md b/docs/ai-docs/security/02-nginx-php-scan-blocking.md index 7225d6c..6e91c60 100644 --- a/docs/ai-docs/security/02-nginx-php-scan-blocking.md +++ b/docs/ai-docs/security/02-nginx-php-scan-blocking.md @@ -1,5 +1,14 @@ # Nginx PHP Scan Blocking +상태: **미적용 (운영 작업 대기)** — 2026-08-10 기준 + +이 저장소에는 nginx 설정 파일이 없어 코드로 반영할 수 없다. +아래 설정을 운영 서버에 직접 적용해야 한다. + +앱 계층 2차 차단([03-app-layer-access-guard-design.md](03-app-layer-access-guard-design.md))은 이미 배포 가능한 상태다. +따라서 nginx 적용 전에도 통계 DB write와 `MultipleObjectsReturned`는 발생하지 않는다. +nginx 적용으로 추가로 얻는 것은 gunicorn까지 도달하는 트래픽량과 로그량 감소다. + ## 목표 PHP/WordPress/phpinfo 탐색 요청을 Django, gunicorn, Sentry까지 보내지 않고 nginx에서 1차로 종료한다. diff --git a/docs/ai-docs/security/03-app-layer-access-guard-design.md b/docs/ai-docs/security/03-app-layer-access-guard-design.md index f43e238..b86e38b 100644 --- a/docs/ai-docs/security/03-app-layer-access-guard-design.md +++ b/docs/ai-docs/security/03-app-layer-access-guard-design.md @@ -1,5 +1,13 @@ # App Layer Access Guard Design +상태: **구현 완료** — 2026-08-10 + +- 구현: `custom_middlewares/middlewares/access_guard.py` +- 등록: `config/settings/base.py`의 `MIDDLEWARE`, `SecurityMiddleware` 바로 뒤 +- 테스트: `custom_middlewares/tests/test_access_guard.py` + +아래 설계안과 실제 구현의 차이는 문서 끝의 "구현과의 차이"에 정리했다. + ## 목표 nginx에서 놓친 비서비스 요청을 Django 애플리케이션 초입에서 2차로 차단한다. 이 방어선은 nginx의 대체물이 아니라 fallback이다. @@ -110,3 +118,24 @@ Django의 `ALLOWED_HOSTS`는 이미 `config/settings/prod.py`에서 환경변수 - `.php` 요청 시 `ConnectionMethodStats`와 `ConnectionHardwareStats`가 증가하지 않는다. - 정상 URL은 기존 응답을 유지한다. - `BLOCK_SUSPICIOUS_PATHS=False`일 때 차단이 비활성화된다. + +네 항목 모두 `custom_middlewares/tests/test_access_guard.py`에 구현됐다. +조기 차단과 일반 404를 구분하기 위해, 차단 응답은 본문이 비어 있다는 점(`response.content == b""`)까지 검증한다. + +## 구현과의 차이 + +- 차단 패턴에 자격증명/설정 파일 유출 스캔을 추가했다: `(^|/)\.(env|git|aws|ssh)(/|$)` +- 차단 시 `blocked suspicious path` INFO 로그를 남긴다. 별도 metric 없이 차단량을 확인할 수 있다. +- "통계 미들웨어와의 연계"에 적은 개선 사항도 함께 반영했다. + - `admin` 부분 문자열 대신 `STATS_EXCLUDED_PATH_PREFIXES` 기반 prefix 판정 + - static/media/silk/robots/sitemap 제외 + - 통계 DB 오류를 `DatabaseError`로 잡아 요청 실패로 번지지 않게 처리 + - 봇은 현행 유지. 하드웨어 통계에 이미 `bot` 컬럼이 있어 별도 분리가 필요 없다. + +## Host 검증 처리 결과 + +문서의 권장 순서 중 앱 계층 몫은 적용했다. + +- 별도 Host guard 미들웨어는 만들지 않았다. `request.get_host()` 호출 자체가 `DisallowedHost`를 발생시켜 얻는 것보다 잃는 게 많다. +- 대신 `config/settings/sub_settings/system/sentry.py`의 `before_send`에서 `DisallowedHost`를 버린다. +- nginx default server 차단과 `ALLOWED_HOSTS` 최소화는 운영 작업으로 남는다. From 4af6802b7f319e21478815b6a06304286a65323f Mon Sep 17 00:00:00 2001 From: bluebamus Date: Mon, 10 Aug 2026 04:15:15 +0000 Subject: [PATCH 08/12] fix(settings): stop prod from failing manage.py check prod listed STATIC_ROOT inside STATICFILES_DIRS, which raises staticfiles.E002. Because system checks run ahead of every management command, prod settings could not run migrate or the new dedupe_connection_stats command at all. stage.py already commented STATICFILES_DIRS out for this reason, noting that it is unnecessary when static files are served from one place. prod now matches: static is served from STATIC_ROOT (ROOT_DIR/static), which already holds the collectstatic output. Co-Authored-By: Claude Opus 5 (1M context) --- config/settings/prod.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/config/settings/prod.py b/config/settings/prod.py index 28e79ef..9247d5e 100644 --- a/config/settings/prod.py +++ b/config/settings/prod.py @@ -135,9 +135,9 @@ STATIC_URL = "/static/" STATIC_DIR = os.path.join(ROOT_DIR, "static") -STATICFILES_DIRS = [ - STATIC_DIR, -] +# STATICFILES_DIRS = [ +# STATIC_DIR, +# ] # OR # STATICFILES_DIRS = [ # BASE_DIR / 'static' @@ -182,6 +182,10 @@ RECAPTCHA_PRIVATE_KEY = config( "RECAPTCHA_PRIVATE_KEY", default="6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe" ) +# 문의 폼 스팸 차단. RECAPTCHA_PUBLIC_KEY/PRIVATE_KEY가 실제 키여야 한다. +# Google 테스트 키를 쓰면 captcha.recaptcha_test_key_error로 check가 실패한다. +CONTACT_FORM_CAPTCHA = True + # SILENCED_SYSTEM_CHECKS = ["captcha.recaptcha_test_key_error"] # RECAPTCHA_DOMAIN = "www.recaptcha.net" From 094f57f9accfa4aa8aeb724cf9c84f5e50c14164 Mon Sep 17 00:00:00 2001 From: bluebamus Date: Mon, 10 Aug 2026 04:15:15 +0000 Subject: [PATCH 09/12] feat(portfolio): add reCAPTCHA to the get-in-touch form The Sentry DataError came from an automated submission, and form validation alone only stops malformed input, not the volume. Gate the form behind reCAPTCHA, which is already installed and configured. CONTACT_FORM_CAPTCHA controls it and defaults to False, so environments without real keys keep working. prod and stage enable it. The field is built in __init__ rather than declared as a class attribute: ReCaptchaField reads the keys when it is constructed, so a class attribute would demand them at import time. Verification calls Google, so tests keep it off and mock captcha.fields.client.submit for the enabled paths. PortfolioView puts the form into the context after the redis cache is written; caching a form instance would share it across every visitor. The template renders the widget only when the field exists. Note: RECAPTCHA_PUBLIC_KEY/PRIVATE_KEY still default to Google's test keys, and django-recaptcha's own system check fails on them, so a deployment cannot silently ship with a decorative captcha. Co-Authored-By: Claude Opus 5 (1M context) --- config/settings/base.py | 6 +- config/settings/stage.py | 4 + portfolio/forms.py | 15 ++++ portfolio/tests/test_get_in_touch_captcha.py | 89 ++++++++++++++++++++ portfolio/views.py | 5 ++ templates/portfolio/portfolio.html | 7 ++ 6 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 portfolio/tests/test_get_in_touch_captcha.py diff --git a/config/settings/base.py b/config/settings/base.py index 68f46dc..4d59732 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -228,4 +228,8 @@ # 문의 폼 이메일 DNS(MX) 검증 사용 여부. # 외부 DNS에 의존하므로 테스트 환경에서는 비활성화한다. EMAIL_DNS_VALIDATION = True -EMAIL_DNS_VALIDATION_TIMEOUT = 10 \ No newline at end of file +EMAIL_DNS_VALIDATION_TIMEOUT = 10 + +# 문의 폼 reCAPTCHA 사용 여부. +# 검증이 Google API 호출을 동반하므로 실제 키가 있는 환경에서만 켠다. +CONTACT_FORM_CAPTCHA = False \ No newline at end of file diff --git a/config/settings/stage.py b/config/settings/stage.py index a8a5707..37b1526 100644 --- a/config/settings/stage.py +++ b/config/settings/stage.py @@ -181,6 +181,10 @@ RECAPTCHA_PRIVATE_KEY = config( "RECAPTCHA_PRIVATE_KEY", default="6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe" ) +# 문의 폼 스팸 차단. RECAPTCHA_PUBLIC_KEY/PRIVATE_KEY가 실제 키여야 한다. +# Google 테스트 키를 쓰면 captcha.recaptcha_test_key_error로 check가 실패한다. +CONTACT_FORM_CAPTCHA = True + # SILENCED_SYSTEM_CHECKS = ["captcha.recaptcha_test_key_error"] # RECAPTCHA_DOMAIN = "www.recaptcha.net" diff --git a/portfolio/forms.py b/portfolio/forms.py index d805cdf..b1a8731 100644 --- a/portfolio/forms.py +++ b/portfolio/forms.py @@ -68,6 +68,21 @@ class GetInTouchForm(forms.Form): error_messages={"required": _("message can't be empty.")}, ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # captcha는 설정으로 켠 환경에서만 붙인다. RECAPTCHA 키가 없는 환경과 + # 외부 호출을 하면 안 되는 테스트에서 폼을 그대로 쓸 수 있어야 한다. + # 필드를 클래스 속성으로 두면 import 시점에 키를 요구하므로 여기서 만든다. + if getattr(settings, "CONTACT_FORM_CAPTCHA", False): + from captcha.fields import ReCaptchaField + + self.fields["captcha"] = ReCaptchaField( + error_messages={ + "required": _("Please confirm that you are not a robot."), + } + ) + def clean_emailfrom(self) -> str: email = self.cleaned_data["emailfrom"] _local, domain = email.rsplit("@", 1) diff --git a/portfolio/tests/test_get_in_touch_captcha.py b/portfolio/tests/test_get_in_touch_captcha.py new file mode 100644 index 0000000..c96205a --- /dev/null +++ b/portfolio/tests/test_get_in_touch_captcha.py @@ -0,0 +1,89 @@ +from unittest import mock + +import pytest +from django.test import override_settings +from django.urls import reverse + +from portfolio.forms import GetInTouchForm +from portfolio.models import GetInTouchLog + +from .test_get_in_touch_form import payload +from .test_get_in_touch_view import HEADERS + +pytestmark = pytest.mark.portfolio + +RECAPTCHA_KEYS = { + "RECAPTCHA_PUBLIC_KEY": "test-public-key", + "RECAPTCHA_PRIVATE_KEY": "test-private-key", +} + + +def recaptcha_result(is_valid): + """captcha.client.submit()의 반환값을 흉내낸다. 네트워크를 타지 않는다.""" + return mock.Mock(is_valid=is_valid, error_codes=[], extra_data={}) + + +def test_captcha_is_absent_when_disabled(): + assert "captcha" not in GetInTouchForm().fields + + +@override_settings(CONTACT_FORM_CAPTCHA=True, **RECAPTCHA_KEYS) +def test_captcha_is_added_when_enabled(): + assert "captcha" in GetInTouchForm().fields + + +@override_settings(CONTACT_FORM_CAPTCHA=True, **RECAPTCHA_KEYS) +def test_captcha_widget_renders_with_the_site_key(): + html = str(GetInTouchForm()["captcha"]) + + assert "g-recaptcha" in html + assert 'data-sitekey="test-public-key"' in html + + +@override_settings(CONTACT_FORM_CAPTCHA=True, **RECAPTCHA_KEYS) +def test_missing_captcha_response_is_rejected(): + form = GetInTouchForm(payload()) + + assert not form.is_valid() + assert "captcha" in form.errors + + +@override_settings(CONTACT_FORM_CAPTCHA=True, **RECAPTCHA_KEYS) +def test_failed_captcha_is_rejected(): + with mock.patch( + "captcha.fields.client.submit", return_value=recaptcha_result(False) + ): + form = GetInTouchForm(payload(**{"g-recaptcha-response": "token"})) + + assert not form.is_valid() + assert "captcha" in form.errors + + +@override_settings(CONTACT_FORM_CAPTCHA=True, **RECAPTCHA_KEYS) +def test_passing_captcha_is_accepted(): + with mock.patch( + "captcha.fields.client.submit", return_value=recaptcha_result(True) + ): + form = GetInTouchForm(payload(**{"g-recaptcha-response": "token"})) + + assert form.is_valid(), form.errors + + +@pytest.mark.django_db +@override_settings(CONTACT_FORM_CAPTCHA=True, **RECAPTCHA_KEYS) +def test_view_rejects_submission_without_captcha(client): + response = client.post(reverse("portfolio:mail"), payload(), **HEADERS) + + assert response.status_code == 302 + assert GetInTouchLog.objects.count() == 0 + + +@pytest.mark.django_db +def test_portfolio_page_provides_the_form(client): + """폼은 캐시가 아니라 요청마다 새로 만들어져야 한다.""" + first = client.get(reverse("portfolio:portfolio"), **HEADERS) + second = client.get(reverse("portfolio:portfolio"), **HEADERS) + + form = first.context["get_in_touch_form"] + assert isinstance(form, GetInTouchForm) + assert second.context["get_in_touch_form"] is not form diff --git a/portfolio/views.py b/portfolio/views.py index e477951..77e00b7 100644 --- a/portfolio/views.py +++ b/portfolio/views.py @@ -93,6 +93,11 @@ def get_context_data(self, **kwargs): logger.debug( f"redis cache - {self.__class__.__name__} caching_data not exists" ) + + # 캐시 저장이 끝난 뒤에 넣는다. 폼은 요청마다 새로 만들어야 하고 + # 캐시에 들어가면 모든 방문자가 같은 인스턴스를 보게 된다. + context["get_in_touch_form"] = GetInTouchForm() + logger.debug(f"final context : {context}") return context diff --git a/templates/portfolio/portfolio.html b/templates/portfolio/portfolio.html index 96ac34a..ce80f7e 100644 --- a/templates/portfolio/portfolio.html +++ b/templates/portfolio/portfolio.html @@ -370,6 +370,13 @@

+ {% if get_in_touch_form.captcha %} +
+
+ {{ get_in_touch_form.captcha }} +
+
+ {% endif %}
From c4e748929d1644177c4de51737a89c567e7626d8 Mon Sep 17 00:00:00 2001 From: bluebamus Date: Mon, 10 Aug 2026 04:15:15 +0000 Subject: [PATCH 10/12] docs: record the captcha and prod static settings changes Co-Authored-By: Claude Opus 5 (1M context) --- docs/ai-docs/README.md | 2 +- docs/ai-docs/handoff/05-agent-handoff.md | 9 ++-- .../ai-docs/plans/04-remediation-work-plan.md | 1 + .../reports/06-implementation-report.md | 47 ++++++++++++++----- 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/docs/ai-docs/README.md b/docs/ai-docs/README.md index 0384c3b..42228db 100644 --- a/docs/ai-docs/README.md +++ b/docs/ai-docs/README.md @@ -4,7 +4,7 @@ ## 진행 상태 -2026-08-10 기준 Phase 1~5 구현이 완료됐고 테스트는 115 passed / 1 skipped다. +2026-08-10 기준 Phase 1~5 구현이 완료됐고 테스트는 123 passed / 1 skipped다. Phase 0(nginx)과 SendGrid 계정 점검은 운영 작업으로 남아 있다. 구현 내용과 배포 절차는 [reports/06-implementation-report.md](reports/06-implementation-report.md)를 본다. diff --git a/docs/ai-docs/handoff/05-agent-handoff.md b/docs/ai-docs/handoff/05-agent-handoff.md index d58542a..f7ec90e 100644 --- a/docs/ai-docs/handoff/05-agent-handoff.md +++ b/docs/ai-docs/handoff/05-agent-handoff.md @@ -6,7 +6,7 @@ - `docs/error`에 Sentry export 5개가 있다. - `docs/error`는 현재 git 기준 미추적 상태로 보인다. 이번 작업에서 바꾸지 않았다. -- 계획서 Phase 1~5의 코드 작업이 끝났다. 테스트는 115 passed / 1 skipped다. +- 계획서 Phase 1~5의 코드 작업이 끝났다. 테스트는 123 passed / 1 skipped다. - 남은 것은 운영 작업이다: nginx 차단 적용, 운영 DB 정리 실행, SendGrid 계정 점검. - 결과 정리는 [../reports/06-implementation-report.md](../reports/06-implementation-report.md)에 있다. @@ -28,7 +28,7 @@ - 탐색성 경로 차단: `custom_middlewares/middlewares/access_guard.py` - 통계 정리 명령: `custom_middlewares/management/commands/dedupe_connection_stats.py` -- 문의 폼: `portfolio/forms.py` +- 문의 폼: `portfolio/forms.py` (reCAPTCHA 포함) - 전화번호 validator: `portfolio/validators.py` - Sentry 이벤트 필터: `config/settings/sub_settings/system/sentry.py` - 테스트: `custom_middlewares/tests/`, `portfolio/tests/` @@ -57,7 +57,9 @@ python manage.py makemigrations users blog board home portfolio custom_middlewar ``` 7. `common/error/error_views.py`의 400/403/404/500 핸들러가 모두 HTTP 200을 반환하고 있었다. 함께 수정했다. -8. `config/settings/prod.py`는 `manage.py check`에서 `staticfiles.E002`로 실패한다. 기존 문제이고 CD는 `stage` 설정을 쓴다. +8. `config/settings/prod.py`가 `manage.py check`에서 `staticfiles.E002`로 실패하고 있었다. + `check`는 모든 관리 명령 앞에서 실행되므로 prod 설정으로는 `migrate`도 정리 명령도 돌릴 수 없었다. + `stage.py`와 같은 형태로 맞춰 해결했다. 네 설정 모두 `check` 오류가 없다. ## 결정된 항목 @@ -83,6 +85,7 @@ python manage.py makemigrations users blog board home portfolio custom_middlewar | `STATS_EXCLUDED_PATH_PREFIXES` | admin/static/media/silk/robots/sitemap | 통계 제외 경로 | | `EMAIL_DNS_VALIDATION` | `True` (test는 `False`) | 문의 폼 이메일 DNS 검증 | | `EMAIL_DNS_VALIDATION_TIMEOUT` | `10` | DNS 조회 타임아웃(초) | +| `CONTACT_FORM_CAPTCHA` | `False` (prod/stage는 `True`) | 문의 폼 reCAPTCHA | ## 검증 명령 diff --git a/docs/ai-docs/plans/04-remediation-work-plan.md b/docs/ai-docs/plans/04-remediation-work-plan.md index e08c997..dc0bb22 100644 --- a/docs/ai-docs/plans/04-remediation-work-plan.md +++ b/docs/ai-docs/plans/04-remediation-work-plan.md @@ -116,6 +116,7 @@ stat_date = models.DateField(unique=True, db_index=True) 4. `check_email_validation_with_dns()`의 `is_valid` 초기화 문제를 수정한다. 5. 도달 불가능한 중복 코드를 제거한다. 6. 스팸성 요청에 대해 rate limit 또는 captcha 적용을 검토한다. 이미 운영 `INSTALLED_APPS`에 `captcha`가 추가되어 있으므로 활용 가능성을 확인한다. + → `CONTACT_FORM_CAPTCHA` 설정으로 reCAPTCHA를 적용했다. prod/stage에서만 켠다. rate limit은 적용하지 않았다. 완료 기준: diff --git a/docs/ai-docs/reports/06-implementation-report.md b/docs/ai-docs/reports/06-implementation-report.md index 00d2b1a..ab9ccfe 100644 --- a/docs/ai-docs/reports/06-implementation-report.md +++ b/docs/ai-docs/reports/06-implementation-report.md @@ -9,10 +9,12 @@ [04-remediation-work-plan.md](../plans/04-remediation-work-plan.md)의 Phase 1~5를 코드로 구현했다. Phase 0(nginx)은 이 저장소에 설정 파일이 없어 운영 작업으로 남는다. -테스트는 `pytest` 기준 **115 passed, 1 skipped**이다. -작업 시작 시점의 기준선은 9 passed, 1 skipped였으므로 106건이 새로 추가되었다. +테스트는 `pytest` 기준 **123 passed, 1 skipped**이다. +작업 시작 시점의 기준선은 9 passed, 1 skipped였으므로 114건이 새로 추가되었다. skip 1건은 이 작업과 무관한 기존 항목(`home.tests.test_home_html.test_home`)이다. +`dev`, `test`, `stage`, `prod` 네 설정 모두 `manage.py check`에서 오류가 없다. + ## 확정된 결정 계획서 "열려 있는 결정"과 "승인 필요 사항"에 대한 답이다. @@ -102,8 +104,21 @@ Django는 `unique=True`일 때 별도 인덱스를 만들지 않으므로 중복 - 수신자는 hidden input `emailto`가 아니라 `settings.DEFAULT_FROM_EMAIL`로 고정된다는 점을 테스트로 고정했다. - `templates/portfolio/portfolio.html`의 입력 필드에 서버 정책과 같은 `maxlength`와 `required`를 추가했다. -captcha 적용은 하지 않았다. `INSTALLED_APPS`에 `captcha`가 있으나 폼 검증만으로 이번 Sentry 이슈는 재발하지 않고, -captcha 도입은 사용자 경험 변경이라 별도 결정이 필요하다. +#### reCAPTCHA + +`django-recaptcha`를 문의 폼에 붙였다. `CONTACT_FORM_CAPTCHA` 설정으로 켜고 끄며, 기본값은 `False`다. +`prod`와 `stage`에서만 켠다. + +- 필드는 클래스 속성이 아니라 `GetInTouchForm.__init__()`에서 만든다. + 클래스 속성으로 두면 import 시점에 RECAPTCHA 키를 요구해서, 키가 없는 환경에서 폼을 쓸 수 없다. +- 검증이 Google API 호출을 동반하므로 테스트에서는 꺼 두고, 켠 경로는 `captcha.fields.client.submit`을 mock해서 검증한다. +- `PortfolioView`는 폼 인스턴스를 redis 캐시 저장이 끝난 뒤에 context에 넣는다. + 캐시에 들어가면 모든 방문자가 같은 인스턴스를 보게 된다. +- 템플릿은 `{% if get_in_touch_form.captcha %}`로 감싸 꺼진 환경에서는 아무것도 렌더하지 않는다. + +**운영 적용 시 주의.** `RECAPTCHA_PUBLIC_KEY` / `RECAPTCHA_PRIVATE_KEY`의 기본값은 Google 테스트 키다. +테스트 키를 그대로 두면 `manage.py check`가 `captcha.recaptcha_test_key_error`로 실패한다. +이는 이 작업 이전부터 있던 동작이고, 실제 키를 넣지 않은 채 배포되는 것을 막아주는 안전장치다. ### Phase 4: 메일 발송 실패 처리 (완료) @@ -161,12 +176,22 @@ return render(request, "errors/error.html", context=context) # 200으로 나 기존 테스트 `home.tests.test_error.test_error_404`는 본문의 "404 Error" 문자열만 검사해 이 문제를 잡지 못했다. +### prod 설정의 `staticfiles.E002` + +`config/settings/prod.py`가 `STATICFILES_DIRS`에 `STATIC_ROOT`와 같은 경로를 넣고 있어 `manage.py check`가 실패했다. +`check`는 모든 관리 명령 앞에서 실행되므로, prod 설정으로는 `migrate`도 `dedupe_connection_stats`도 돌릴 수 없었다. + +`stage.py`는 이미 같은 이유로 `STATICFILES_DIRS`를 주석 처리하고 +"static 파일을 한 곳에 모아서 서비스 할 경우 상위 STATICFILES_DIRS 변수는 불필요함"이라고 적어 두었다. +prod도 같은 형태로 맞췄다. static은 `STATIC_ROOT`(`ROOT_DIR/static`) 한 곳에서 서비스하고, +이 디렉터리에는 이미 collectstatic 결과물(`admin/`, `silk/`, `summernote/` 등)이 들어 있다. + ## 검증 ### 테스트 ``` -115 passed, 1 skipped +123 passed, 1 skipped ``` 새로 추가한 테스트 파일: @@ -179,6 +204,7 @@ return render(request, "errors/error.html", context=context) # 200으로 나 | `custom_middlewares/tests/test_sentry_filter.py` | 노이즈 이벤트 폐기, 실제 오류 보존 | | `portfolio/tests/test_get_in_touch_form.py` | 입력 정책, 전화번호 길이/형식, DNS 예외 분기 | | `portfolio/tests/test_get_in_touch_view.py` | 접수/발송 분리, DataError 재발 방지, 수신자 고정 | +| `portfolio/tests/test_get_in_touch_captcha.py` | captcha 토글, 위젯 렌더, 검증 성공/실패 | | `portfolio/tests/test_email_sending.py` | 발송 결과 반환, PII 마스킹 | 기존 `custom_middlewares/tests.py`와 `portfolio/tests.py`는 내용 없는 스텁이라 같은 이름의 패키지로 대체했다. @@ -239,10 +265,10 @@ python manage.py dedupe_connection_stats --settings=config.settings.prod # - gunicorn access log에 .php 요청이 사라졌는지 ``` -`config/settings/prod.py`는 `manage.py check`에서 `staticfiles.E002` -(`STATICFILES_DIRS`가 `STATIC_ROOT`를 포함) 오류가 난다. -이 작업과 무관한 기존 문제이고 CD 파이프라인은 `config.settings.stage`를 쓴다. -prod 설정으로 위 명령을 실행해야 한다면 `--skip-checks`를 붙이거나 static 설정을 먼저 정리한다. +`config/settings/prod.py`의 `staticfiles.E002`(`STATICFILES_DIRS`가 `STATIC_ROOT`를 포함)는 함께 정리했다. +`stage.py`가 이미 같은 이유로 `STATICFILES_DIRS`를 주석 처리해 두었기에 prod도 같은 형태로 맞췄다. +static 파일은 `STATIC_ROOT`(`ROOT_DIR/static`) 한 곳에서 서비스하며, 이 디렉터리에는 이미 collectstatic 결과물이 들어 있다. +이제 `--skip-checks` 없이 prod 설정으로 위 명령을 실행할 수 있다. ## 남은 작업 @@ -251,8 +277,7 @@ prod 설정으로 위 명령을 실행해야 한다면 `--skip-checks`를 붙이 | nginx 차단 location 적용 | 운영자 | Phase 0 | | SendGrid API key / sender 인증 / 크레딧 점검 | 운영자 | Phase 4의 근본 원인 | | 운영 DB 백업 후 dedupe 실행 | 운영자 | 위 배포 절차 | -| `staticfiles.E002` 정리 | 별도 이슈 | 기존 문제 | -| 문의 폼 captcha 적용 여부 | 결정 필요 | 스팸이 계속되면 검토 | +| RECAPTCHA 실제 키 설정 | 운영자 | 테스트 키면 `check`가 실패한다 | | 메일 발송의 Celery 이관 | 후속 | 아래 참고 | ### 동기 발송의 트레이드오프 From 19365841437a1b310efd9a5c402d7d1b84817896 Mon Sep 17 00:00:00 2001 From: bluebamus Date: Mon, 10 Aug 2026 04:27:04 +0000 Subject: [PATCH 11/12] test: cover the changed code that had no tests Coverage on the modules this branch touches was 83%, with two gaps that mattered: - both statistics changelist_view methods were at 0%, even though "the existing admin statistics screen still works" is an acceptance criterion of the remediation plan - only the 404 error handler was exercised, so the 400/403/500 fixes were unverified Also covers EmailThread, which this branch rewrote and which the signup verification mail still uses, plus the remaining branches in the statistics middleware and the get-in-touch form. Every module changed on this branch is now at 100% line coverage. Separately, clear the redis cache around each test. The cache outlives a test run, so a leftover key flipped cached/uncached branches and made coverage of PortfolioView swing between 62% and 78% depending on what had run before. Two consecutive runs now report identical coverage. Co-Authored-By: Claude Opus 5 (1M context) --- .coverage | Bin 0 -> 53248 bytes conftest.py | 13 +++ custom_middlewares/tests/test_statistics.py | 59 ++++++++++++ custom_middlewares/tests/test_stats_admin.py | 95 +++++++++++++++++++ home/tests/test_error_pages.py | 55 +++++++++++ portfolio/tests/test_email_thread.py | 69 ++++++++++++++ portfolio/tests/test_get_in_touch_form.py | 28 ++++++ 7 files changed, 319 insertions(+) create mode 100644 .coverage create mode 100644 custom_middlewares/tests/test_stats_admin.py create mode 100644 home/tests/test_error_pages.py create mode 100644 portfolio/tests/test_email_thread.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..b81ecf594e1436589d9c542ec3ccdf97daaa9a9c GIT binary patch literal 53248 zcmeI4du$xV9mjX?*1PxIv16alj$d0*a4Z~OETCx`h+~2i0)d2wcqD<%`fhD6+}$3# zdvVM|_EJ>TLe&bXQfUkHB~_}bpjPFfgcL|CwW?Af`VWPuqzQ<}A2h963M8cNZ{}`& zYhtIqvn7Q2tN&j;@TDR!(!2i@17@=edoF!f{*_{^IcG zZ4&GV-T_$Nw?E%*leb4i zVwvMgLAR6o~BS%m9zIKmOi3edOoe&B`;PPnVv|`S0^JZaUe&jV8@&<=X`k`vs~E)x6K@FS*h===Jb%$7A@r}=8e0G z`b^{ZDwUkQtQvWOsKbV1&+LR}1kQhy}G51>V4&_ zrD5UI4kQSj{;-%F>Y6WfWRR@q3iLda{$Ifha1Qclm%hWP@UD1m>g`IuR7&)YJYhr)FbG)ME~k$Rf3+W zbb(Jy1Pgb+pgCj8*}l`$;COePm|UNj&vEL4+E3rbukH#kmmh3p^m8!i)4$*>m+6^` z%UA0YtM_VI7)=VAVL_LLF}O4XC^TV4gT&gfS#((Z(nA)WbndD6TN0=od+Z@q4@(Z+ zdxvS;2287T+OogmomINWG@Fh>cDFKYnps`Tm*xh@vDJn$4O?M8oINWIR5a`fsxi8^ zoB8BTE_9f`5jvgMzhEbq)AE_}d_||>k}8b-v~$vON`nYwRJx&4+R#Nmq3Ma1uhJ3A z)9)()6RMiiGNtZL8^xzMKBk`~SDKBk&>Y<{a=Jp3FlSg`jcc}&wsg1%b7@a^@LXX) zTTAVy?{t+MqPKEx7cV6*ypVT`<<3|dXH**|^_=FpT|NxV3X)G;ti;Q97;xck#~&w@ zoeu{+ER{e9w^9oZmne9t%J({v9=gEi$!QK=xFG=~fCP{L5ihgKmter2_OL^fCP{L5c51JokK6F* zp4kaf=|xGw!-en|Z%)rUAj2Ltw84{1v7oEqVP0pC{HnI2!Nbahi9H=q;Lws6sH#TZ za8wma*x3#xtXp^qGq6&Qo-1VOGl|-$otinV4r{iq!a301(FWS*FIwB@?Z%a$SXYap z*MtMDpb)G@p=f1klk8}zu2MzE$l57*meRoGMQP^DjLycfp)hEXg=@`>@F{qb-+<@fO^b%-Uh zt`tEgv2ai3=!9BDK#wokADF^~J%?~~=ZViCQ+A8W)x?QS_ z9CHjKaJNK5;5Htl2bNaa=Q;iUze^nCBD*6ES%0qg%xO>l>nzChpX+ObP;*HuwMsDum9Vx6WsF`ukG`; zZBTIQYEtx?aOFn94c4SkUjMgNStfG!9mMa7|T9Zq@^?&jf!M&&okvZ;ueWB?? z-dr4hF z;mI7Gu)N?FuMymi#VPsxs;ha%DZ3-oJU{rn2<{Zzwbj&V;p_hZ3?c*Jg^%y$Z(`2* z(Spx&{QWPkq6|9L<;qj3 zYd$JWsvkUm`5|ennd7Et{f|ER;g%=YoqBXYYK27gtv{C%EwIzuvSIqo4o>1YcPa?U zUN|l#>GkAW*NV-sBRBKS39x;MQyUK*@Bhc&?|DM|{bOfNAG$S~XrlR>zVz@8L2ujgLINj3NH*(~={cngXsKyFD*#LG;!`%rk@atmw!}Ro)Uryii>hz&# zw4SD}55y1u?%n1%frEDPbU>Io^Zm&?M44um`89E{!|_wolJa=3+V?l<$`=Qf`Y(^W z>m$3Tz7V?ik@Zdc9^syy?h*RdG#`0*!y5zf7!``${py>0;KhqkDir1A2-wcZf!LJ! z)+;Z)LZmPp*M~P>wk8BS!=by+`@`u!-FxhV_fJfo&K(;#aq{TNx4Zv)+B|w}VBnum zpLu=zbIHknH9mjp%zqk1iAqYmC{in0VjZ=0yeLpBSYnXcAm0%Hdud?*_h=iung6(R z`o5F)hL;ZhHUG@f_r5kz#|z=G%+v4xJINZ3oF@M!?~%92G4dDkGJFp3Tk;%vitHyp zBR?dMl1IoxMjFkzl5&iJ8VmW>&0VrlEnE z`g&#vVMdmjiN~3V#h8gknTbT035S^pg_w~fW<>TG0bf_gjs$_3V33(WK!6Vb*!TZj z{W5DLtPT=D0!RP}AOR$R1dsp{Kmter2_OL^FpB{F{vX%>vyk8c5cJ59aw7s literal 0 HcmV?d00001 diff --git a/conftest.py b/conftest.py index df238ec..9ce6535 100644 --- a/conftest.py +++ b/conftest.py @@ -1,5 +1,6 @@ import factory import pytest +from django.core.cache import cache from pytest_factoryboy import register from board.models.board import Notice @@ -10,6 +11,18 @@ register(FakeUserFactory) +@pytest.fixture(autouse=True) +def clear_redis_cache(): + """테스트 DB와 달리 redis 캐시는 실행 사이에 초기화되지 않는다. + + 남은 키가 캐시 히트/미스 분기를 바꿔서 같은 테스트가 실행 순서와 이전 + 실행 결과에 따라 다른 코드 경로를 타게 된다. 매 테스트 전후로 비운다. + """ + cache.clear() + yield + cache.clear() + + # help to use session scope with fixture of db and django_db # @pytest.fixture(scope='session') # def django_db_setup(django_db_setup, django_db_blocker): diff --git a/custom_middlewares/tests/test_statistics.py b/custom_middlewares/tests/test_statistics.py index 09d3c38..f1764eb 100644 --- a/custom_middlewares/tests/test_statistics.py +++ b/custom_middlewares/tests/test_statistics.py @@ -152,6 +152,65 @@ def test_database_error_does_not_break_the_request(client): assert response.status_code == 200 +@pytest.mark.parametrize( + "flags,expected", + [ + ({"is_mobile": True}, "mobile"), + ({"is_tablet": True}, "tablet"), + ({"is_pc": True}, "pc"), + ({"is_bot": True}, "bot"), + ], +) +def test_hardware_field_resolution(flags, expected): + defaults = { + "is_mobile": False, + "is_tablet": False, + "is_pc": False, + "is_bot": False, + } + defaults.update(flags) + + assert ( + ConnectionHardwareStatsMiddleware.resolve_field(mock.Mock(**defaults)) + == expected + ) + + +@pytest.mark.django_db +def test_request_without_user_agent_header_is_not_counted_by_method_stats(): + middleware = ConnectionMethodStatsMiddleware(lambda request: None) + request = mock.Mock(path_info="/portfolio/", META={}) + + middleware.record(request) + + assert ConnectionMethodStats.objects.count() == 0 + + +@pytest.mark.django_db +def test_request_without_user_agent_object_is_not_counted_by_hardware_stats(): + middleware = ConnectionHardwareStatsMiddleware(lambda request: None) + request = mock.Mock(path_info="/portfolio/", spec=["path_info"]) + + middleware.record(request) + + assert ConnectionHardwareStats.objects.count() == 0 + + +@pytest.mark.django_db +def test_unclassifiable_user_agent_is_not_counted(): + middleware = ConnectionHardwareStatsMiddleware(lambda request: None) + request = mock.Mock( + path_info="/portfolio/", + user_agent=mock.Mock( + is_mobile=False, is_tablet=False, is_pc=False, is_bot=False + ), + ) + + middleware.record(request) + + assert ConnectionHardwareStats.objects.count() == 0 + + def test_hardware_field_resolution_returns_none_for_unknown_agent(): unknown = mock.Mock( is_mobile=False, is_tablet=False, is_pc=False, is_bot=False diff --git a/custom_middlewares/tests/test_stats_admin.py b/custom_middlewares/tests/test_stats_admin.py new file mode 100644 index 0000000..1364094 --- /dev/null +++ b/custom_middlewares/tests/test_stats_admin.py @@ -0,0 +1,95 @@ +import json +from datetime import timedelta + +import pytest +from django.urls import reverse +from django.utils import timezone + +from custom_middlewares.models import ( + ConnectionHardwareStats, + ConnectionMethodStats, +) +from users.models import User + +pytestmark = [pytest.mark.middlewares, pytest.mark.django_db] + + +@pytest.fixture +def admin_client(client): + User.objects.create_superuser( + username="statsadmin", + email="statsadmin@devspoon.com", + password="password", + ) + client.login(username="statsadmin", password="password") + return client + + +def stat_data_of(response): + return json.loads(response.context["stat_data"]) + + +def test_method_stats_changelist_shows_today(admin_client): + today = timezone.localdate() + ConnectionMethodStats.objects.create(stat_date=today, win=4, mac=2) + + response = admin_client.get( + reverse("home_admin:home_connectionmethodstats_changelist") + ) + + assert response.status_code == 200 + assert stat_data_of(response) == [ + {"win": 4, "mac": 2, "iph": 0, "android": 0, "oth": 0} + ] + + +def test_method_stats_changelist_ignores_same_day_of_other_months(admin_client): + """created_at__day은 '일'만 비교해 다른 달의 row까지 집계에 섞였다.""" + today = timezone.localdate() + ConnectionMethodStats.objects.create(stat_date=today, win=4) + # 같은 '일'이지만 다른 달 + ConnectionMethodStats.objects.create( + stat_date=today - timedelta(days=28), win=999 + ) + + response = admin_client.get( + reverse("home_admin:home_connectionmethodstats_changelist") + ) + + stat_data = stat_data_of(response) + assert len(stat_data) == 1 + assert stat_data[0]["win"] == 4 + + +def test_hardware_stats_changelist_shows_today(admin_client): + today = timezone.localdate() + ConnectionHardwareStats.objects.create(stat_date=today, pc=7, mobile=3) + + response = admin_client.get( + reverse("home_admin:home_connectionhardwarestats_changelist") + ) + + assert response.status_code == 200 + assert stat_data_of(response) == [ + {"mobile": 3, "tablet": 0, "pc": 7, "bot": 0} + ] + + +def test_changelist_works_with_no_rows_for_today(admin_client): + response = admin_client.get( + reverse("home_admin:home_connectionmethodstats_changelist") + ) + + assert response.status_code == 200 + assert stat_data_of(response) == [] + + +def test_changelist_renders_stat_date_column(admin_client): + """list_display는 모델 필드에서 만들어지므로 stat_date가 포함되어야 한다.""" + ConnectionMethodStats.objects.create(stat_date=timezone.localdate(), win=1) + + response = admin_client.get( + reverse("home_admin:home_connectionmethodstats_changelist") + ) + + assert b"stat_date" in response.content.lower().replace(b"-", b"_") diff --git a/home/tests/test_error_pages.py b/home/tests/test_error_pages.py new file mode 100644 index 0000000..90f869f --- /dev/null +++ b/home/tests/test_error_pages.py @@ -0,0 +1,55 @@ +import pytest +from django.urls import reverse + +from common.error.error_views import ( + bad_request_page, + page_not_found_page, + permission_denied_page, + server_error_page, +) + +# 에러 템플릿이 공통 base를 상속해 사이트 정보를 조회하므로 DB 접근이 필요하다. +pytestmark = [pytest.mark.http_error, pytest.mark.django_db] + +HANDLERS = [ + (bad_request_page, 400), + (permission_denied_page, 403), + (page_not_found_page, 404), + (server_error_page, 500), +] + + +@pytest.mark.parametrize("handler,expected_status", HANDLERS) +def test_error_handlers_return_their_status(rf, handler, expected_status): + """이전 구현은 status_code를 세팅한 응답을 버리고 render()의 200을 반환했다.""" + response = handler(rf.get("/boom")) + + assert response.status_code == expected_status + + +@pytest.mark.parametrize("handler,expected_status", HANDLERS) +def test_error_handlers_render_the_status_in_the_page( + rf, handler, expected_status +): + response = handler(rf.get("/boom")) + + assert str(expected_status).encode() in response.content + + +@pytest.mark.django_db +def test_csrf_failure_redirects_home(rf): + from common.error.error_views import csrf_failure + + response = csrf_failure(rf.post("/portfolio/mail"), reason="no token") + + assert response.status_code == 302 + assert response.url == reverse("home:index") + + +@pytest.mark.django_db +def test_unknown_url_answers_404_end_to_end(client): + """handler404를 거친 실제 응답도 404여야 한다.""" + response = client.get("/no-such-page-here/") + + assert response.status_code == 404 + assert b"404 Error" in response.content diff --git a/portfolio/tests/test_email_thread.py b/portfolio/tests/test_email_thread.py new file mode 100644 index 0000000..1e73247 --- /dev/null +++ b/portfolio/tests/test_email_thread.py @@ -0,0 +1,69 @@ +from unittest import mock + +import pytest +from django.core import mail + +from utils.email.async_send_email import EmailThread, send_mail + +pytestmark = pytest.mark.portfolio + +# 회원가입 인증 메일(utils/email/verify_email_mixins.py)이 쓰는 경로다. +# 문의 폼은 send_mail_sync를 쓰지만 이 경로는 그대로 남아 있으므로 함께 검증한다. + + +def test_email_thread_sends_and_records_the_result(): + thread = EmailThread( + "hello", + "body", + "admin@devspoon.com", + ["user@devspoon.com"], + "

body

", + False, + ) + + thread.start() + thread.join(timeout=5) + + assert thread.result is True + assert len(mail.outbox) == 1 + assert mail.outbox[0].alternatives == [("

body

", "text/html")] + + +def test_email_thread_records_failure_without_raising(): + with mock.patch( + "django.core.mail.EmailMultiAlternatives.send", + side_effect=Exception("HTTP Error 401: Unauthorized"), + ): + thread = EmailThread( + "hello", "body", "admin@devspoon.com", ["user@devspoon.com"], None, False + ) + thread.start() + thread.join(timeout=5) + + assert thread.result is False + + +def test_send_mail_starts_a_thread_and_returns_immediately(): + with mock.patch( + "utils.email.async_send_email.EmailThread" + ) as thread_class: + result = send_mail( + subject="hello", + recipient_list=["user@devspoon.com"], + message="body", + from_email="admin@devspoon.com", + ) + + assert result is None + thread_class.return_value.start.assert_called_once() + + +def test_plain_text_message_has_no_html_alternative(): + thread = EmailThread( + "hello", "body", "admin@devspoon.com", ["user@devspoon.com"], None, False + ) + + thread.start() + thread.join(timeout=5) + + assert mail.outbox[0].alternatives == [] diff --git a/portfolio/tests/test_get_in_touch_form.py b/portfolio/tests/test_get_in_touch_form.py index 755242d..c2a8b4e 100644 --- a/portfolio/tests/test_get_in_touch_form.py +++ b/portfolio/tests/test_get_in_touch_form.py @@ -134,3 +134,31 @@ def test_domain_without_mx_record_is_rejected(): assert not form.is_valid() assert "emailfrom" in form.errors + + +@override_settings(EMAIL_DNS_VALIDATION=True) +def test_falsy_validation_result_is_rejected(): + """validate_email이 예외 없이 False를 돌려주는 경로.""" + with pytest.MonkeyPatch.context() as patch: + patch.setattr("portfolio.forms.validate_email", lambda **kwargs: False) + form = GetInTouchForm(payload()) + + assert not form.is_valid() + assert "emailfrom" in form.errors + + +@override_settings(EMAIL_DNS_VALIDATION=True) +def test_dns_validation_runs_when_enabled(): + calls = {} + + def fake_validate(**kwargs): + calls.update(kwargs) + return True + + with pytest.MonkeyPatch.context() as patch: + patch.setattr("portfolio.forms.validate_email", fake_validate) + assert GetInTouchForm(payload()).is_valid() + + assert calls["check_dns"] is True + assert calls["check_smtp"] is False + assert calls["email_address"] == "hong@example.com" From ef85548cfa8feae00bc53b2b4e9873d00ca6575a Mon Sep 17 00:00:00 2001 From: bluebamus Date: Mon, 10 Aug 2026 04:46:20 +0000 Subject: [PATCH 12/12] fix: restore log detail and make block logging reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the app end-to-end against PostgreSQL surfaced two problems that the test suite could not see. Masking the recipient moved the address and the failure reason into extra=, but the project's log formatter does not render extra fields. File logs were left with a bare "Error sending email" — less useful than before this branch, since the original line at least named the recipient. Put the masked value and the error back in the message and keep extra= for Sentry. The same applied to access_guard, statistics and the get-in-touch form. The blocked-path line never appeared at all: blocking is normal behaviour so it logs at INFO, but COMMON_LOGGER is set to WARNING and filtered it out. The implementation report claimed blocked volume could be read from this log, which was untrue. SUSPICIOUS_PATH_LOG_LEVEL now controls it, and the report says what the default actually does. Co-Authored-By: Claude Opus 5 (1M context) --- config/settings/base.py | 3 + .../middlewares/access_guard.py | 14 +++- custom_middlewares/middlewares/statistics.py | 5 +- docs/ai-docs/README.md | 2 +- docs/ai-docs/handoff/05-agent-handoff.md | 3 +- .../reports/06-implementation-report.md | 70 +++++++++++++++++-- portfolio/forms.py | 5 +- utils/email/async_send_email.py | 11 ++- 8 files changed, 98 insertions(+), 15 deletions(-) diff --git a/config/settings/base.py b/config/settings/base.py index 4d59732..ce34fa2 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -211,6 +211,9 @@ # nginx가 1차로 끊는 것이 원칙이고, 아래 설정은 애플리케이션 fallback이다. BLOCK_SUSPICIOUS_PATHS = True SUSPICIOUS_PATH_RESPONSE_STATUS = 404 +# 차단은 정상 동작이라 INFO가 맞지만 COMMON_LOGGER가 WARNING이라 기록되지 않는다. +# 차단량을 Django 로그로 집계하려면 "WARNING"으로 올린다. +SUSPICIOUS_PATH_LOG_LEVEL = "INFO" # 접속 통계 집계에서 제외할 경로 prefix (custom_middlewares.middlewares.statistics) STATS_EXCLUDED_PATH_PREFIXES = [ diff --git a/custom_middlewares/middlewares/access_guard.py b/custom_middlewares/middlewares/access_guard.py index 268e028..a94f6f5 100644 --- a/custom_middlewares/middlewares/access_guard.py +++ b/custom_middlewares/middlewares/access_guard.py @@ -35,6 +35,12 @@ def __init__(self, get_response): self.get_response = get_response self.enabled = getattr(settings, "BLOCK_SUSPICIOUS_PATHS", True) self.status = getattr(settings, "SUSPICIOUS_PATH_RESPONSE_STATUS", 404) + # 차단은 정상 동작이라 기본은 INFO다. 다만 COMMON_LOGGER는 WARNING이라 + # 기본 설정에서는 기록되지 않는다. 차단량을 로그로 집계해야 할 때 + # 이 값을 "WARNING"으로 올린다. + self.log_level = logging.getLevelName( + getattr(settings, "SUSPICIOUS_PATH_LOG_LEVEL", "INFO") + ) raw_patterns = getattr( settings, "SUSPICIOUS_PATH_PATTERNS", @@ -46,8 +52,12 @@ def __init__(self, get_response): def __call__(self, request): if self.enabled and self.is_suspicious_path(request.path_info): - logger.info( - "blocked suspicious path", + logger.log( + self.log_level, + "blocked suspicious path %s %s -> %s", + request.method, + request.path_info, + self.status, extra={ "path": request.path_info, "method": request.method, diff --git a/custom_middlewares/middlewares/statistics.py b/custom_middlewares/middlewares/statistics.py index 9e00392..c9d1909 100644 --- a/custom_middlewares/middlewares/statistics.py +++ b/custom_middlewares/middlewares/statistics.py @@ -75,7 +75,10 @@ def __call__(self, request): self.record(request) except DatabaseError as error: logger.warning( - "failed to record connection stats", + "failed to record connection stats in %s for %s: %s", + self.__class__.__name__, + request.path_info, + error, extra={ "middleware": self.__class__.__name__, "path": request.path_info, diff --git a/docs/ai-docs/README.md b/docs/ai-docs/README.md index 42228db..5940c10 100644 --- a/docs/ai-docs/README.md +++ b/docs/ai-docs/README.md @@ -4,7 +4,7 @@ ## 진행 상태 -2026-08-10 기준 Phase 1~5 구현이 완료됐고 테스트는 123 passed / 1 skipped다. +2026-08-10 기준 Phase 1~5 구현이 완료됐고 테스트는 151 passed / 1 skipped다(sqlite·PostgreSQL 양쪽). Phase 0(nginx)과 SendGrid 계정 점검은 운영 작업으로 남아 있다. 구현 내용과 배포 절차는 [reports/06-implementation-report.md](reports/06-implementation-report.md)를 본다. diff --git a/docs/ai-docs/handoff/05-agent-handoff.md b/docs/ai-docs/handoff/05-agent-handoff.md index f7ec90e..08f60d7 100644 --- a/docs/ai-docs/handoff/05-agent-handoff.md +++ b/docs/ai-docs/handoff/05-agent-handoff.md @@ -6,7 +6,7 @@ - `docs/error`에 Sentry export 5개가 있다. - `docs/error`는 현재 git 기준 미추적 상태로 보인다. 이번 작업에서 바꾸지 않았다. -- 계획서 Phase 1~5의 코드 작업이 끝났다. 테스트는 123 passed / 1 skipped다. +- 계획서 Phase 1~5의 코드 작업이 끝났다. 테스트는 151 passed / 1 skipped이고 sqlite와 PostgreSQL 양쪽에서 통과한다. - 남은 것은 운영 작업이다: nginx 차단 적용, 운영 DB 정리 실행, SendGrid 계정 점검. - 결과 정리는 [../reports/06-implementation-report.md](../reports/06-implementation-report.md)에 있다. @@ -82,6 +82,7 @@ python manage.py makemigrations users blog board home portfolio custom_middlewar | `BLOCK_SUSPICIOUS_PATHS` | `True` | 탐색성 경로 차단 on/off | | `SUSPICIOUS_PATH_RESPONSE_STATUS` | `404` | 차단 응답 status | | `SUSPICIOUS_PATH_PATTERNS` | 기본 패턴 목록 | 차단 정규식 | +| `SUSPICIOUS_PATH_LOG_LEVEL` | `"INFO"` | 차단 로그 레벨. 기본값은 common 로거(WARNING)에 걸려 기록되지 않는다 | | `STATS_EXCLUDED_PATH_PREFIXES` | admin/static/media/silk/robots/sitemap | 통계 제외 경로 | | `EMAIL_DNS_VALIDATION` | `True` (test는 `False`) | 문의 폼 이메일 DNS 검증 | | `EMAIL_DNS_VALIDATION_TIMEOUT` | `10` | DNS 조회 타임아웃(초) | diff --git a/docs/ai-docs/reports/06-implementation-report.md b/docs/ai-docs/reports/06-implementation-report.md index ab9ccfe..eaf7a1f 100644 --- a/docs/ai-docs/reports/06-implementation-report.md +++ b/docs/ai-docs/reports/06-implementation-report.md @@ -9,8 +9,9 @@ [04-remediation-work-plan.md](../plans/04-remediation-work-plan.md)의 Phase 1~5를 코드로 구현했다. Phase 0(nginx)은 이 저장소에 설정 파일이 없어 운영 작업으로 남는다. -테스트는 `pytest` 기준 **123 passed, 1 skipped**이다. -작업 시작 시점의 기준선은 9 passed, 1 skipped였으므로 114건이 새로 추가되었다. +테스트는 `pytest` 기준 **151 passed, 1 skipped**이다. sqlite와 운영과 같은 PostgreSQL 양쪽에서 통과한다. +작업 시작 시점의 기준선은 9 passed, 1 skipped였으므로 142건이 새로 추가되었다. +이 브랜치가 변경한 모듈은 모두 라인 커버리지 100%다. skip 1건은 이 작업과 무관한 기존 항목(`home.tests.test_home_html.test_home`)이다. `dev`, `test`, `stage`, `prod` 네 설정 모두 `manage.py check`에서 오류가 없다. @@ -154,7 +155,12 @@ Django는 `unique=True`일 때 별도 인덱스를 만들지 않으므로 중복 - 버리는 대상: `Http404`, `DisallowedHost`, `SuspiciousOperation` 예외와 `.php`/`wp-*`/`.env` 계열 URL에서 난 이벤트. - `config/settings/prod.py`와 `config/settings/stage.py`의 `sentry_sdk.init()`에 연결했다. -차단량 집계는 별도 metric을 두지 않고 nginx access log와 미들웨어의 INFO 로그(`blocked suspicious path`)로 확인한다. +차단량 집계는 별도 metric을 두지 않고 nginx access log와 미들웨어 로그(`blocked suspicious path`)로 확인한다. + +단, 기본 설정에서는 이 로그가 **남지 않는다.** 차단은 정상 동작이라 로그 레벨이 INFO인데 +`COMMON_LOGGER`("common") 로거가 WARNING이라 걸러진다. +Django 로그로 차단량을 집계하려면 `SUSPICIOUS_PATH_LOG_LEVEL = "WARNING"`으로 올린다. +E2E에서 올렸을 때 실제로 기록되는 것을 확인했다. 테스트는 `pytest.ini`에 `portfolio`, `middlewares` 마커를 추가해 CI에서 자동 수집된다. @@ -186,12 +192,46 @@ return render(request, "errors/error.html", context=context) # 200으로 나 prod도 같은 형태로 맞췄다. static은 `STATIC_ROOT`(`ROOT_DIR/static`) 한 곳에서 서비스하고, 이 디렉터리에는 이미 collectstatic 결과물(`admin/`, `silk/`, `summernote/` 등)이 들어 있다. +### 에러 페이지 버그가 실제 장애를 가리고 있었다 + +E2E 중에 `/users/login/`이 `SocialApp.DoesNotExist`로 크래시하는 것을 발견했다. +템플릿이 google/kakao/naver provider를 참조하는데 해당 `SocialApp` row가 없어서다. +이 브랜치는 `users/`를 건드리지 않았으므로 기존 문제다. + +중요한 것은 이 페이지가 **수정 전에는 HTTP 200으로 응답했다**는 점이다. + +| | 반환 status | +| --- | --- | +| 수정 전(`c9b0eae`) | **200** (본문에는 500이라고 적혀 있음) | +| 수정 후 | **500** | + +즉 에러 페이지 버그가 실제 서버 오류를 가리고 있었다. +배포하면 그동안 200으로 집계되던 실패가 제대로 5xx로 드러난다. +**모니터링에 5xx가 갑자기 나타나더라도 새로 생긴 장애가 아니라 원래 있던 장애가 보이기 시작한 것일 수 있다.** +운영의 `/users/login/`도 같은 상태인지 확인이 필요하다. + +### 로그에서 상세가 사라지던 문제 + +PII 마스킹을 넣으면서 수신자와 오류 원인을 `extra=`로만 넘겼는데, +프로젝트의 로그 포맷터는 `extra` 필드를 렌더하지 않는다. +그 결과 파일 로그에는 `Error sending email`만 남고 **어느 주소로 왜 실패했는지가 사라졌다.** +원래 코드보다 운영성이 나빠진 회귀였고 E2E에서 발견했다. + +마스킹된 값과 오류를 메시지 본문에 넣고 `extra`는 구조화 소비자(Sentry)용으로 유지하도록 고쳤다. + +``` +ERROR [async_send_email.py:...] Error sending email to ['a***n@devspoon.com']: [Errno 111] Connection refused +``` + +같은 이유로 `access_guard`, `statistics`, `forms`의 로그도 함께 고쳤다. + ## 검증 ### 테스트 ``` -123 passed, 1 skipped +151 passed, 1 skipped # sqlite +151 passed, 1 skipped # postgresql 16 ``` 새로 추가한 테스트 파일: @@ -211,7 +251,7 @@ prod도 같은 형태로 맞췄다. static은 `STATIC_ROOT`(`ROOT_DIR/static`) ### 마이그레이션 리허설 -실제 운영 업그레이드 순서를 sqlite로 재현해 검증했다. +실제 운영 업그레이드 순서를 재현해 검증했다. sqlite와 **운영과 같은 PostgreSQL 16** 양쪽에서 수행했다. 1. 변경 전(HEAD) 모델로 스키마를 만들고 2. 같은 날짜 중복 row를 심고 (`2026-08-05` 2건, `2026-08-06` 3건) @@ -224,6 +264,26 @@ prod도 같은 형태로 맞췄다. static은 `STATIC_ROOT`(`ROOT_DIR/static`) - 날짜별 1 row로 병합됐고 카운트가 정확히 합산됐다. (`2026-08-05`: win 10+7 = 17) - DB 레벨 유니크 제약이 실제로 동작한다. (중복 INSERT 시 `UNIQUE constraint failed`) - 두 번째 실행은 아무것도 바꾸지 않았다. +- PostgreSQL에서 `stat_date`에 **NULL을 여러 개 넣을 수 있음**을 직접 확인했다. + `stat_date`를 nullable로 두고 마이그레이션 1회로 끝내는 설계의 근거다. + +전체 테스트도 두 엔진에서 각각 통과한다(151 passed / 1 skipped). + +### 실제 서버 E2E + +`runserver`를 띄우고 PostgreSQL에 붙여 HTTP로 직접 확인했다. + +| 시나리오 | 결과 | +| --- | --- | +| `/wp.php`, `/site/phpinfo.php`, `/bbs/board.php?...`, `/wp-admin/`, `/.env`, `/xmlrpc.php` | 전부 404, 본문 **0바이트** | +| 오탈자 URL(`/no-such-page`) | 404, 본문 15,920바이트 (에러 페이지 정상 렌더) | +| 메인 페이지 `/` | 200. sqlite에서 막혀 skip되던 페이지가 운영 엔진에서는 정상이다 | +| 16자 초과 전화번호 제출 | 302 redirect, **DB row 생성 안 됨** (DataError 재발 없음) | +| 정상 문의 제출 | 저장 + `status=sent` + 메일 파일 생성, 수신자는 `DEFAULT_FROM_EMAIL` | +| 메일 벤더 장애(연결 거부) 재현 | 문의는 저장되고 `status=failed`. 사용자에겐 지연 안내 | +| captcha ON | `g-recaptcha` 위젯과 api.js 렌더, captcha 없는 제출은 저장되지 않음 | +| `/robots.txt`, `/admin/home/`, 차단 경로 요청 | 통계 카운터 변화 없음 | +| 로그 | 수신자가 `['a***n@devspoon.com']`로 마스킹되고 원문 노출 0건 | ### 확인된 마이그레이션 동작 diff --git a/portfolio/forms.py b/portfolio/forms.py index b1a8731..7fc2844 100644 --- a/portfolio/forms.py +++ b/portfolio/forms.py @@ -113,7 +113,8 @@ def clean_emailfrom(self) -> str: ) except DNS_INFRASTRUCTURE_ERRORS as error: logger.warning( - "email dns validation unavailable", + "email dns validation unavailable: %s", + error, extra={"error": str(error)}, ) return email @@ -121,7 +122,7 @@ def clean_emailfrom(self) -> str: # 이전 구현은 이 경로에서 초기화되지 않은 is_valid를 반환해 # UnboundLocalError가 날 수 있었다. logger.debug( - "email validation failed", extra={"error": str(error)} + "email validation failed: %s", error, extra={"error": str(error)} ) raise forms.ValidationError(INVALID_EMAIL_MESSAGE) diff --git a/utils/email/async_send_email.py b/utils/email/async_send_email.py index 0a48e7b..5dd5f8c 100644 --- a/utils/email/async_send_email.py +++ b/utils/email/async_send_email.py @@ -63,17 +63,22 @@ def send_mail_sync( except Exception as error: # 메일 벤더 인증/크레딧 문제 등 외부 요인이 대부분이다. logger.error( - "Error sending email", + "Error sending email to %s: %s", + masked, + error, extra={"recipients": masked, "error": str(error)}, ) return False if sent_count > 0: - logger.info("Email sent successfully", extra={"recipients": masked}) + logger.info( + "Email sent successfully to %s", masked, extra={"recipients": masked} + ) return True logger.warning( - "Email not sent. No recipients were successfully sent.", + "Email not sent to %s. No recipients were successfully sent.", + masked, extra={"recipients": masked}, ) return False