Files
OnlineJudge/conf/views.py
yuetsh 989b33d827 refactor: 移除 2FA 功能与三个冗余依赖
2FA 早已是死代码:TOTP 相关端点标注为前端未调用,前端唯一的
two_factor_auth 是硬编码 false,没有任何入口能打开它。

- 删除 TwoFactorAuthAPI、CheckTFARequiredAPI、_totp* 辅助函数、
  TwoFactorAuthCodeSerializer 及各 serializer 的 tfa_code 字段
- 删除管理端写 two_factor_auth/tfa_token 的分支。登录已不再校验
  TOTP,若保留写入路径,置 True 会变成静默的安全降级
- 删除 User.two_factor_auth / tfa_token 字段(迁移 0009)
- 移除 django-dbconn-retry:仅挂在 INSTALLED_APPS,代码零引用。
  DATABASES 未配置 CONN_MAX_AGE(默认 0),本就没有持久连接需要
  重连修复。要开持久连接用 Django 自带的 CONN_HEALTH_CHECKS
- requests 换成 httpx(openai 已经引入):删除废弃的
  ReleaseNotesAPI,judge dispatcher 显式传 timeout=None 以保持
  requests 原本的无超时行为,判题请求是同步等结果的

依赖净减 5 个:otpauth、qrcode、requests、charset-normalizer、
django-dbconn-retry

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:00:45 -06:00

312 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import hashlib
import os
import random
import re
import shutil
import smtplib
from datetime import timedelta
from asgiref.sync import sync_to_async
from django.conf import settings
from django.utils import timezone
from account.decorators import super_admin_required
from account.models import User
from contest.models import Contest
from judge.dispatcher import process_pending_task
from options.options import SysOptions
from problem.models import Problem
from submission.models import Submission
from utils.api import APIView, AsyncAPIView, CSRFExemptAPIView, validate_serializer
from utils.cache import JsonDataLoader
from utils.shortcuts import get_env, send_email
from utils.websocket import push_config_update
from utils.xss_filter import XSSHtml
from .models import JudgeServer
from .serializers import (
CreateEditWebsiteConfigSerializer,
CreateSMTPConfigSerializer,
EditJudgeServerSerializer,
EditSMTPConfigSerializer,
JudgeServerHeartbeatSerializer,
JudgeServerSerializer,
TestSMTPConfigSerializer,
)
# DEPRECATED: 前端未调用 (2026-05-26)
class SMTPAPI(APIView):
@super_admin_required
def get(self, request):
smtp = SysOptions.smtp_config
if not smtp:
return self.success(None)
smtp.pop("password")
return self.success(smtp)
@super_admin_required
@validate_serializer(CreateSMTPConfigSerializer)
def post(self, request):
SysOptions.smtp_config = request.data
return self.success()
@super_admin_required
@validate_serializer(EditSMTPConfigSerializer)
def put(self, request):
smtp = SysOptions.smtp_config
data = request.data
for item in ["server", "port", "email", "tls"]:
smtp[item] = data[item]
if "password" in data:
smtp["password"] = data["password"]
SysOptions.smtp_config = smtp
return self.success()
# DEPRECATED: 前端未调用 (2026-05-26)
class SMTPTestAPI(APIView):
@super_admin_required
@validate_serializer(TestSMTPConfigSerializer)
def post(self, request):
if not SysOptions.smtp_config:
return self.error("Please setup SMTP config at first")
try:
send_email(
smtp_config=SysOptions.smtp_config,
from_name=SysOptions.website_name_shortcut,
to_name=request.user.username,
to_email=request.data["email"],
subject="You have successfully configured SMTP",
content="You have successfully configured SMTP",
)
except smtplib.SMTPResponseException as e:
# guess error message encoding
msg = b"Failed to send email"
try:
msg = e.smtp_error
# qq mail
msg = msg.decode("gbk")
except Exception:
msg = msg.decode("utf-8", "ignore")
return self.error(msg)
except Exception as e:
msg = str(e)
return self.error(msg)
return self.success()
class WebsiteConfigAPI(AsyncAPIView):
async def get(self, request):
ret = await SysOptions.aget_many(
"website_base_url",
"website_name",
"website_name_shortcut",
"website_footer",
"allow_register",
"submission_list_show_all",
"class_list",
"enable_maxkb",
)
return self.success(ret)
@super_admin_required
@validate_serializer(CreateEditWebsiteConfigSerializer)
async def post(self, request):
@sync_to_async
def _update_config(data):
for k, v in data.items():
if k == "website_footer":
with XSSHtml() as parser:
v = parser.clean(v)
setattr(SysOptions, k, v)
push_config_update(k, v)
await _update_config(request.data)
return self.success()
class JudgeServerAPI(APIView):
@super_admin_required
def get(self, request):
servers = JudgeServer.objects.all().order_by("-last_heartbeat")
return self.success(
{
"token": SysOptions.judge_server_token,
"servers": JudgeServerSerializer(servers, many=True).data,
}
)
@super_admin_required
def delete(self, request):
hostname = request.GET.get("hostname")
if hostname:
JudgeServer.objects.filter(hostname=hostname).delete()
return self.success()
@validate_serializer(EditJudgeServerSerializer)
@super_admin_required
def put(self, request):
is_disabled = request.data.get("is_disabled", False)
JudgeServer.objects.filter(id=request.data["id"]).update(is_disabled=is_disabled)
if not is_disabled:
process_pending_task()
return self.success()
class JudgeServerHeartbeatAPI(CSRFExemptAPIView):
@validate_serializer(JudgeServerHeartbeatSerializer)
def post(self, request):
data = request.data
client_token = request.META.get("HTTP_X_JUDGE_SERVER_TOKEN")
if hashlib.sha256(SysOptions.judge_server_token.encode("utf-8")).hexdigest() != client_token:
return self.error("Invalid token")
try:
server = JudgeServer.objects.get(hostname=data["hostname"])
server.judger_version = data["judger_version"]
server.cpu_core = data["cpu_core"]
server.memory_usage = data["memory"]
server.cpu_usage = data["cpu"]
server.service_url = data["service_url"]
server.ip = request.ip
server.last_heartbeat = timezone.now()
server.save(
update_fields=[
"judger_version",
"cpu_core",
"memory_usage",
"service_url",
"ip",
"last_heartbeat",
]
)
except JudgeServer.DoesNotExist:
JudgeServer.objects.create(
hostname=data["hostname"],
judger_version=data["judger_version"],
cpu_core=data["cpu_core"],
memory_usage=data["memory"],
cpu_usage=data["cpu"],
ip=request.META["REMOTE_ADDR"],
service_url=data["service_url"],
last_heartbeat=timezone.now(),
)
# 新server上线 处理队列中的防止没有新的提交而导致一直waiting
process_pending_task()
return self.success()
# DEPRECATED: 前端未调用 (2026-05-26)
class LanguagesAPI(APIView):
def get(self, request):
return self.success(
{
"languages": SysOptions.languages,
}
)
class TestCasePruneAPI(APIView):
@super_admin_required
def get(self, request):
"""
return orphan test_case list
"""
ret_data = []
dir_to_be_removed = self.get_orphan_ids()
# return an iterator
for d in os.scandir(settings.TEST_CASE_DIR):
if d.name in dir_to_be_removed:
ret_data.append({"id": d.name, "create_time": d.stat().st_mtime})
return self.success(ret_data)
@super_admin_required
def delete(self, request):
test_case_id = request.GET.get("id")
if test_case_id:
self.delete_one(test_case_id)
return self.success()
for id in self.get_orphan_ids():
self.delete_one(id)
return self.success()
@staticmethod
def get_orphan_ids():
db_ids = Problem.objects.all().values_list("test_case_id", flat=True)
disk_ids = os.listdir(settings.TEST_CASE_DIR)
test_case_re = re.compile(r"^[a-zA-Z0-9]{32}$")
disk_ids = filter(lambda f: test_case_re.match(f), disk_ids)
return list(set(disk_ids) - set(db_ids))
@staticmethod
def delete_one(id):
test_case_dir = os.path.join(settings.TEST_CASE_DIR, id)
if os.path.isdir(test_case_dir):
shutil.rmtree(test_case_dir, ignore_errors=True)
class DashboardInfoAPI(AsyncAPIView):
async def get(self, request):
now = timezone.now()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
(
user_count,
today_submission_count,
recent_contest_count,
judge_servers,
) = await asyncio.gather(
User.objects.acount(),
Submission.objects.filter(create_time__gte=today_start).acount(),
Contest.objects.exclude(end_time__lt=timezone.now()).acount(),
JudgeServer.objects.filter(last_heartbeat__gte=timezone.now() - timedelta(seconds=6)).acount(),
)
return self.success(
{
"user_count": user_count,
"recent_contest_count": recent_contest_count,
"today_submission_count": today_submission_count,
"judge_server_count": judge_servers,
"env": {
"FORCE_HTTPS": get_env("FORCE_HTTPS", default=False),
"STATIC_CDN_HOST": get_env("STATIC_CDN_HOST", default=""),
},
}
)
class RandomUsernameAPI(AsyncAPIView):
async def get(self, request):
classroom = request.GET.get("classroom", "")
if not classroom:
return self.error("需要班级号")
usernames = [u async for u in User.objects.filter(username__istartswith=classroom).values_list("username", flat=True).order_by("?")[:10]]
return self.success(usernames)
class HitokotoAPI(AsyncAPIView):
async def get(self, request):
try:
categories = JsonDataLoader.load_data(settings.HITOKOTO_DIR, "categories.json")
path = random.choice(categories).get("path")
sentences = JsonDataLoader.load_data(settings.HITOKOTO_DIR, path)
sentence = random.choice(sentences)
return self.success(sentence)
except Exception:
return self.error("获取一言失败,请稍后再试")
class ClassUsernamesAPI(AsyncAPIView):
async def get(self, request):
classroom = request.GET.get("classroom", "")
if not classroom:
return self.error("需要班级号")
prefix = f"ks{classroom}"
names = [
user.username[len(prefix) :] if user.username.startswith(prefix) else user.username async for user in User.objects.filter(class_name=classroom).order_by("-create_time")
]
return self.success(names)