f22786d746
auth.py 的 _EnvelopeMixin/_ok 提取到 tracker/api/envelope.py(去下划线前缀), auth.py 改为 import 使用;collect.py 的 finalize_response 含 ACAO 回显逻辑 保留在自身视图,仅注释指向未来收敛。 Co-Authored-By: Claude Code <noreply@anthropic.com>
113 lines
5.9 KiB
Python
113 lines
5.9 KiB
Python
import json
|
||
from datetime import datetime, timezone as dt_tz
|
||
from urllib.parse import urlsplit
|
||
from rest_framework.views import APIView
|
||
from rest_framework.response import Response
|
||
from rest_framework.permissions import AllowAny
|
||
from rest_framework.throttling import AnonRateThrottle
|
||
from rest_framework.parsers import BaseParser
|
||
from rest_framework.settings import api_settings
|
||
from tracker.models import Site
|
||
from tracker.services.ingest import ingest_batch
|
||
|
||
class PlainJSONParser(BaseParser):
|
||
media_type = "text/plain"
|
||
def parse(self, stream, *args, **kwargs):
|
||
try:
|
||
return json.loads(stream.read().decode("utf-8") or "{}")
|
||
except (ValueError, UnicodeDecodeError):
|
||
from rest_framework.exceptions import ParseError
|
||
raise ParseError("invalid_json") # DRF 对 parser 异常是 re-raise 不是包装,不自包就是 500
|
||
|
||
class CollectThrottle(AnonRateThrottle):
|
||
scope = "collect"
|
||
|
||
def get_rate(self):
|
||
# SimpleRateThrottle.THROTTLE_RATES 在 import 时固化为类属性,
|
||
# override_settings 改 DEFAULT_THROTTLE_RATES 对它不生效——改读实时 api_settings(DRF 收 setting_changed 会 reload)
|
||
return api_settings.DEFAULT_THROTTLE_RATES[self.scope]
|
||
|
||
def _iso(dt):
|
||
if isinstance(dt, str):
|
||
s = dt.replace("Z", "+00:00")
|
||
d = datetime.fromisoformat(s)
|
||
return d if d.tzinfo else d.replace(tzinfo=dt_tz.utc) # Django≥5 已删除 django.utils.timezone.utc
|
||
return dt
|
||
|
||
class CollectBatchView(APIView):
|
||
permission_classes = [AllowAny]
|
||
parser_classes = [PlainJSONParser]
|
||
throttle_classes = [CollectThrottle]
|
||
authentication_classes = []
|
||
|
||
def post(self, request):
|
||
origin = request.headers.get("Origin") or ""
|
||
cl = request.headers.get("Content-Length") or "0"
|
||
if not cl.isdecimal() or int(cl) > 262144: # isdecimal:isdigit 放过 "²"(int 仍崩),isdecimal 与 int() 精确对齐
|
||
return self._deny(400, "payload_too_large") # 应用层兜底(nginx 另设 client_max_body_size)
|
||
site = self._match_site(request)
|
||
if site is None or not site.is_active:
|
||
return self._deny(403, "not_allowed") # 三种拒绝统一 reason,防 site_id 枚举探测
|
||
# urlsplit 提取 host:split("//")[-1] 会被查询串里的白名单 URL 绕过;
|
||
# "null" Origin(隐私上下文/沙箱 iframe)视为无 host,走 allow_no_origin 分支
|
||
host = (urlsplit(origin).hostname or "").lower() if origin and origin != "null" else ""
|
||
if host and host not in [d.lower() for d in site.domains]:
|
||
return self._deny(403, "not_allowed")
|
||
if not host and not site.allow_no_origin:
|
||
return self._deny(403, "not_allowed")
|
||
payload = request.data if isinstance(request.data, dict) else {}
|
||
for ev in payload.get("events") or []:
|
||
if isinstance(ev, dict) and "occurred_at" in ev:
|
||
try:
|
||
ev["occurred_at"] = _iso(ev["occurred_at"])
|
||
except (ValueError, TypeError):
|
||
return self._deny(400, "invalid_occurred_at")
|
||
if not payload.get("visitor_id") or not payload.get("session_id"):
|
||
return self._deny(400, "invalid_payload")
|
||
ip = self._client_ip(request)
|
||
r = ingest_batch(site, payload, ip, request.headers.get("User-Agent", ""))
|
||
return Response({"code": 200,
|
||
"data": {"accepted": r["accepted"], "duplicated": r["duplicated"]},
|
||
"message": "ok"})
|
||
|
||
def options(self, request): # 预检兜底(SDK text/plain 简单请求通常不触发);ACAO 由 finalize_response 统一补
|
||
resp = Response(status=200)
|
||
resp["Access-Control-Allow-Methods"] = "POST, OPTIONS"
|
||
resp["Access-Control-Allow-Headers"] = "content-type"
|
||
return resp
|
||
|
||
def finalize_response(self, request, response, *args, **kwargs):
|
||
"""统一收口:ACAO 回显 + 错误信封。
|
||
|
||
错误信封部分与 tracker.api.envelope.EnvelopeMixin 同构——collect 端含 ACAO 回显
|
||
(浏览器 SDK 跨域需读错误体),暂保留在自身视图,未来收敛至共享模块。
|
||
|
||
429/ParseError 等由 DRF 异常处理器产出的响应(无 ACAO、body 是 {detail})在这里补齐——
|
||
否则浏览器 SDK 跨域读不到错误体会误判网络错误而重试,限流时反而打爆限流器。
|
||
dispatch 对 initial() 抛出的 Throttled/ParseError 同样走 finalize_response,此钩子全覆盖。
|
||
"""
|
||
response = super().finalize_response(request, response, *args, **kwargs)
|
||
origin = request.headers.get("Origin")
|
||
if origin and "Access-Control-Allow-Origin" not in response: # 只回显真正的 Origin 头
|
||
response["Access-Control-Allow-Origin"] = origin
|
||
if response.status_code >= 400 and isinstance(response.data, dict) and "detail" in response.data:
|
||
response.data = {"code": response.status_code, "data": None,
|
||
"message": response.data["detail"]}
|
||
return response
|
||
|
||
def _match_site(self, request):
|
||
sid = (request.data or {}).get("site_id") if isinstance(request.data, dict) else None
|
||
return Site.objects.filter(site_id=sid).first() if sid else None
|
||
|
||
def _client_ip(self, request):
|
||
"""nginx 前置时取 X-Forwarded-For 最后一段(与 DRF NUM_PROXIES=1 一致;套 CDN 时按层数前移)。"""
|
||
xff = request.META.get("HTTP_X_FORWARDED_FOR", "")
|
||
ip = xff.split(",")[-1].strip() if xff else request.META.get("REMOTE_ADDR")
|
||
# 归一 IPv4 映射地址(::ffff:1.2.3.4 → 1.2.3.4),防地理解析丢失
|
||
if ip and ip.lower().startswith("::ffff:"):
|
||
ip = ip[7:]
|
||
return ip
|
||
|
||
def _deny(self, code, reason):
|
||
return Response({"code": code, "data": None, "message": reason}, status=code) # ACAO/信封由 finalize_response 统一补
|