Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .coverage
Binary file not shown.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 16 additions & 17 deletions common/error/error_views.py
Original file line number Diff line number Diff line change
@@ -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"))
Expand All @@ -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)
Expand Down
34 changes: 33 additions & 1 deletion config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -203,4 +205,34 @@
ACCOUNT_SIGNUP_REDIRECT_URL = "users:profile"

SMTP_HOST=config("SMTP_HOST","devspoon.com")
SMTP_FROM_ADDRESS=config("SMTP_FROM_ADDRESS","admin@devspoon.com")
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
# 차단은 정상 동작이라 INFO가 맞지만 COMMON_LOGGER가 WARNING이라 기록되지 않는다.
# 차단량을 Django 로그로 집계하려면 "WARNING"으로 올린다.
SUSPICIOUS_PATH_LOG_LEVEL = "INFO"

# 접속 통계 집계에서 제외할 경로 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

# 문의 폼 reCAPTCHA 사용 여부.
# 검증이 Google API 호출을 동반하므로 실제 키가 있는 환경에서만 켠다.
CONTACT_FORM_CAPTCHA = False
13 changes: 10 additions & 3 deletions config/settings/prod.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -134,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'
Expand Down Expand Up @@ -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
Expand All @@ -179,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"

Expand Down
7 changes: 7 additions & 0 deletions config/settings/stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -178,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"

Expand Down
44 changes: 44 additions & 0 deletions config/settings/sub_settings/system/sentry.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions config/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand Down
24 changes: 10 additions & 14 deletions custom_middlewares/admin/home_statistics_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -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"))
Expand Down
File renamed without changes.
Empty file.
Loading
Loading