Files
chunyu_project/shorturl/views.py
T
root 3618323192 fix(security): P1/P2 审计修复 + JWT HttpOnly Cookie 双模认证 + 限流
- P0/P1 审计修复: 滑块验证码不再下发 x_position/成败即销毁 key、
  user-login 补失败计数+滑块门控、限流标识改 X-Real-IP、
  百度翻译 appkey 环境化、ChangeEmail/ChangePhone 补调 avalidate、
  logs/tasks.py Count(filter=Q) 修复、chat 收藏 SSRF 内网黑名单
- P1 #6/7: token_blacklist + ROTATE_REFRESH_TOKENS 开启,
  /user/token/refresh/ 挂载
- #2 JWT HttpOnly Cookie 双模认证: user/cookie_auth.py 种/清 Cookie,
  user/authentication.py CookieOrHeaderJWTAuthentication(Bearer 优先/_COOKIE 兜底),
  user/views/token.py CookieTokenRefreshView + UserLogoutAPIView(/user/logout/),
  create_standardized_response 自动对含 token 的响应种 Cookie,
  异步视图内 RefreshToken.for_user 全部 sync_to_async 包裹(修 SynchronousOnlyOperation 500),
  WS ChatConsumer 优先读 Cookie token
- P2 #11 限流: utils/rate_limit.py 固定窗口频控,
  shorturl 生成 匿名10次/分+登录60次/分, 邮箱验证码 同邮箱60s1次+同IP10次/10min,
  登录/注册验证码 错5次作废+成功即销毁防重放, 换绑邮箱/手机 同步落地,
  urls.py 补挂 shorturl 路由(此前 404)
2026-09-08 11:28:00 +08:00

231 lines
8.4 KiB
Python

import re
from django.http import HttpResponseRedirect, HttpResponseNotFound, HttpResponseGone
from django.utils import timezone
from rest_framework.permissions import AllowAny, IsAuthenticated
from adrf.views import APIView
from rest_framework.response import Response
from rest_framework import status
from asgiref.sync import sync_to_async
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from chunyu_project.common_schemas import success_response, error_response, unauthorized_response
from utils.rate_limit import check_rate_limit, get_client_ip
from .models import ShortUrl
BASE62_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
def encode_base62(num):
if num == 0:
return BASE62_ALPHABET[0]
result = []
while num > 0:
result.append(BASE62_ALPHABET[num % 62])
num //= 62
return ''.join(reversed(result))
def is_valid_url(url):
if not url:
return False
return url.startswith('http://') or url.startswith('https://')
def is_valid_custom_code(code):
return bool(re.match(r'^[a-zA-Z0-9_-]{3,20}$', code))
class ShortUrlShortenView(APIView):
permission_classes = [AllowAny]
@swagger_auto_schema(
tags=['ShortUrl'],
operation_summary='生成短链接',
operation_description='将长URL转换为短链接,支持自定义短码和过期时间',
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['url'],
properties={
'url': openapi.Schema(type=openapi.TYPE_STRING, description='需要缩短的长URL'),
'custom_code': openapi.Schema(type=openapi.TYPE_STRING, description='自定义短码(3-20字符,仅限字母数字、连字符、下划线)'),
'expire_days': openapi.Schema(type=openapi.TYPE_INTEGER, description='过期天数,为空则永不过期'),
},
),
responses={200: success_response, 400: error_response, 409: error_response}
)
async def post(self, request):
# 频控加固:未登录单 IP 每分钟限 10 次,已登录限 60 次
is_auth = request.user and request.user.is_authenticated
identifier = str(request.user.id) if is_auth else get_client_ip(request)
limit = 60 if is_auth else 10
if identifier and not await sync_to_async(check_rate_limit)('shorturl_shorten', identifier, limit=limit, window_seconds=60):
return Response(
{"code": 429, "message": "生成短链接过于频繁,请稍后再试"},
status=status.HTTP_429_TOO_MANY_REQUESTS
)
url = request.data.get('url', '').strip()
custom_code = request.data.get('custom_code', '').strip() or None
expire_days = request.data.get('expire_days')
if not is_valid_url(url):
return Response(
{"code": 400, "message": "请输入有效的URL(以 http:// 或 https:// 开头)"},
status=status.HTTP_400_BAD_REQUEST
)
if custom_code:
if not is_valid_custom_code(custom_code):
return Response(
{"code": 400, "message": "自定义短码仅允许字母、数字、连字符、下划线,长度3-20字符"},
status=status.HTTP_400_BAD_REQUEST
)
if await ShortUrl.objects.filter(code=custom_code).aexists():
return Response(
{"code": 409, "message": "该短码已被使用,请更换"},
status=status.HTTP_409_CONFLICT
)
code = custom_code
else:
last = await ShortUrl.objects.order_by('-id').afirst()
next_id = (last.id + 1) if last else 1
code = encode_base62(next_id)
while await ShortUrl.objects.filter(code=code).aexists():
next_id += 1
code = encode_base62(next_id)
expire_at = None
if expire_days:
try:
days = int(expire_days)
if days > 0:
expire_at = timezone.now() + timezone.timedelta(days=days)
except (ValueError, TypeError):
pass
user = request.user if request.user.is_authenticated else None
short_url = await ShortUrl.objects.acreate(
code=code,
original_url=url,
custom_code=custom_code,
creator=user,
expire_at=expire_at,
)
short_url_base = request.build_absolute_uri('/s/')
if not short_url_base.endswith('/'):
short_url_base += '/'
return Response({
"code": 0,
"data": {
"code": short_url.code,
"short_url": f"{short_url_base}{short_url.code}/",
"original_url": short_url.original_url,
"expire_at": short_url.expire_at.isoformat() if short_url.expire_at else None,
"created_at": short_url.created_at.isoformat(),
}
}, status=status.HTTP_200_OK)
class ShortUrlInfoView(APIView):
permission_classes = [AllowAny]
@swagger_auto_schema(
tags=['ShortUrl'],
operation_summary='查询短链接信息',
operation_description='通过短码查询短链接的详细信息',
responses={200: success_response, 404: error_response}
)
async def get(self, request, code):
try:
short_url = await ShortUrl.objects.aget(code=code)
except ShortUrl.DoesNotExist:
return Response(
{"code": 404, "message": "短链接不存在"},
status=status.HTTP_404_NOT_FOUND
)
is_expired = short_url.expire_at and short_url.expire_at < timezone.now()
return Response({
"code": 0,
"data": {
"code": short_url.code,
"original_url": short_url.original_url,
"custom_code": short_url.custom_code,
"created_at": short_url.created_at.isoformat(),
"expire_at": short_url.expire_at.isoformat() if short_url.expire_at else None,
"click_count": short_url.click_count,
"is_expired": is_expired,
}
})
class ShortUrlRedirectView(APIView):
permission_classes = [AllowAny]
async def get(self, request, code):
try:
short_url = await ShortUrl.objects.aget(code=code)
except ShortUrl.DoesNotExist:
return HttpResponseNotFound('<h1>404 - 短链接不存在</h1>')
if short_url.expire_at and short_url.expire_at < timezone.now():
return HttpResponseGone('<h1>410 - 短链接已过期</h1>')
await ShortUrl.objects.filter(pk=short_url.pk).aupdate(click_count=short_url.click_count + 1)
return HttpResponseRedirect(short_url.original_url)
class ShortUrlListView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
tags=['ShortUrl'],
operation_summary='获取当前用户的短链接列表',
operation_description='分页返回当前登录用户创建的所有短链接',
responses={200: success_response, 401: unauthorized_response}
)
async def get(self, request):
queryset = ShortUrl.objects.filter(creator=request.user).order_by('-created_at')
try:
page = max(1, int(request.GET.get('page', 1)))
except (ValueError, TypeError):
page = 1
try:
page_size = max(1, min(int(request.GET.get('page_size', 20)), 100))
except (ValueError, TypeError):
page_size = 20
start = (page - 1) * page_size
end = start + page_size
total = await queryset.acount()
results = [item async for item in queryset[start:end]]
data = [{
"code": item.code,
"short_url": request.build_absolute_uri(f'/s/{item.code}/'),
"original_url": item.original_url,
"click_count": item.click_count,
"expire_at": item.expire_at.isoformat() if item.expire_at else None,
"created_at": item.created_at.isoformat(),
} for item in results]
return Response({
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"results": data,
}
})