- 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)
231 lines
8.4 KiB
Python
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,
|
|
}
|
|
})
|