diff --git a/account/decorators.py b/account/decorators.py index 839893f1..36a2b44f 100644 --- a/account/decorators.py +++ b/account/decorators.py @@ -1,10 +1,7 @@ import functools - -from utils.api import JSONResponse - -from .models import ProblemPermission - from contest.models import Contest, ContestType, ContestStatus, ContestRuleType +from utils.api import JSONResponse, APIError +from .models import ProblemPermission class BasePermissionDecorator(object): @@ -90,8 +87,7 @@ def check_contest_permission(check_type="details"): if not user.is_authenticated(): return self.error("Please login first.") # password error - if ("accessible_contests" not in request.session) or \ - (self.contest.id not in request.session["accessible_contests"]): + if self.contest.id not in request.session.get("accessible_contests", []): return self.error("Password is required.") # regular user get contest problems, ranks etc. before contest started @@ -104,7 +100,10 @@ def check_contest_permission(check_type="details"): return self.error(f"No permission to get {check_type}") return func(*args, **kwargs) - return _check_permission - return decorator + + +def ensure_created_by(obj, user): + if not user.is_admin_role() or (user.is_admin() and obj.created_by != user): + raise APIError(msg=f"{obj.__class__.__name__} does not exist") diff --git a/account/serializers.py b/account/serializers.py index fe54862f..e14ab8e1 100644 --- a/account/serializers.py +++ b/account/serializers.py @@ -86,7 +86,7 @@ class UserProfileSerializer(serializers.ModelSerializer): class EditUserSerializer(serializers.Serializer): id = serializers.IntegerField() username = serializers.CharField(max_length=32) - real_name = serializers.CharField(max_length=32, allow_blank=True) + real_name = serializers.CharField(max_length=32, allow_blank=True, allow_null=True) password = serializers.CharField(min_length=6, allow_blank=True, required=False, default=None) email = serializers.EmailField(max_length=64) admin_type = serializers.ChoiceField(choices=(AdminType.REGULAR_USER, AdminType.ADMIN, AdminType.SUPER_ADMIN)) diff --git a/conf/tests.py b/conf/tests.py index 55f4853d..c6a49afc 100644 --- a/conf/tests.py +++ b/conf/tests.py @@ -153,3 +153,32 @@ class TestCasePruneAPITest(APITestCase): resp = self.client.delete(self.url) self.assertSuccess(resp) mocked_delete_one.assert_called_once_with(valid_id) + + +class ReleaseNoteAPITest(APITestCase): + def setUp(self): + self.url = self.reverse("get_release_notes_api") + self.create_super_admin() + self.latest_data = {"update": [ + { + "version": "2099-12-25", + "level": 1, + "title": "Update at 2099-12-25", + "details": ["test get", ] + } + ]} + + def test_get_versions(self): + resp = self.client.get(self.url) + self.assertSuccess(resp) + + +class DashboardInfoAPITest(APITestCase): + def setUp(self): + self.url = self.reverse("dashboard_info_api") + self.create_admin() + + def test_get_info(self): + resp = self.client.get(self.url) + self.assertSuccess(resp) + self.assertEqual(resp.data["data"]["user_count"], 1) diff --git a/conf/urls/admin.py b/conf/urls/admin.py index 5b194b84..8e6d293f 100644 --- a/conf/urls/admin.py +++ b/conf/urls/admin.py @@ -1,6 +1,7 @@ from django.conf.urls import url from ..views import SMTPAPI, JudgeServerAPI, WebsiteConfigAPI, TestCasePruneAPI, SMTPTestAPI +from ..views import ReleaseNotesAPI, DashboardInfoAPI urlpatterns = [ url(r"^smtp/?$", SMTPAPI.as_view(), name="smtp_admin_api"), @@ -8,4 +9,6 @@ urlpatterns = [ url(r"^website/?$", WebsiteConfigAPI.as_view(), name="website_config_api"), url(r"^judge_server/?$", JudgeServerAPI.as_view(), name="judge_server_api"), url(r"^prune_test_case/?$", TestCasePruneAPI.as_view(), name="prune_test_case_api"), + url(r"^versions/?$", ReleaseNotesAPI.as_view(), name="get_release_notes_api"), + url(r"^dashboard_info", DashboardInfoAPI.as_view(), name="dashboard_info_api"), ] diff --git a/conf/views.py b/conf/views.py index 972312d1..62c1d350 100644 --- a/conf/views.py +++ b/conf/views.py @@ -2,17 +2,25 @@ import os import re import hashlib import shutil +import json +import pytz +import requests +from datetime import datetime +from requests.exceptions import RequestException from django.utils import timezone from django.conf import settings from account.decorators import super_admin_required from problem.models import Problem +from account.models import User +from submission.models import Submission +from contest.models import Contest from judge.dispatcher import process_pending_task from judge.languages import languages, spj_languages from options.options import SysOptions from utils.api import APIView, CSRFExemptAPIView, validate_serializer -from utils.shortcuts import send_email +from utils.shortcuts import send_email, get_env from utils.xss_filter import XSSHtml from .models import JudgeServer from .serializers import (CreateEditWebsiteConfigSerializer, @@ -30,14 +38,14 @@ class SMTPAPI(APIView): smtp.pop("password") return self.success(smtp) - @validate_serializer(CreateSMTPConfigSerializer) @super_admin_required + @validate_serializer(CreateSMTPConfigSerializer) def post(self, request): SysOptions.smtp_config = request.data return self.success() - @validate_serializer(EditSMTPConfigSerializer) @super_admin_required + @validate_serializer(EditSMTPConfigSerializer) def put(self, request): smtp = SysOptions.smtp_config data = request.data @@ -81,8 +89,8 @@ class WebsiteConfigAPI(APIView): "website_footer", "allow_register", "submission_list_show_all"]} return self.success(ret) - @validate_serializer(CreateEditWebsiteConfigSerializer) @super_admin_required + @validate_serializer(CreateEditWebsiteConfigSerializer) def post(self, request): for k, v in request.data.items(): if k == "website_footer": @@ -156,7 +164,7 @@ class TestCasePruneAPI(APIView): @super_admin_required def get(self, request): """ - return isolated test_case list + return orphan test_case list """ ret_data = [] dir_to_be_removed = self.get_orphan_ids() @@ -190,3 +198,36 @@ class TestCasePruneAPI(APIView): 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 ReleaseNotesAPI(APIView): + def get(self, request): + try: + resp = requests.get("https://raw.githubusercontent.com/QingdaoU/OnlineJudge/master/docs/data.json", + timeout=3) + releases = resp.json() + except (RequestException, ValueError): + return self.success() + with open("docs/data.json", "r") as f: + local_version = json.load(f)["update"][0]["version"] + releases["local_version"] = local_version + return self.success(releases) + + +class DashboardInfoAPI(APIView): + def get(self, request): + today = datetime.today() + today_submission_count = Submission.objects.filter( + create_time__gte=datetime(today.year, today.month, today.day, 0, 0, tzinfo=pytz.UTC)).count() + recent_contest_count = Contest.objects.exclude(end_time__lt=timezone.now()).count() + judge_server_count = len(list(filter(lambda x: x.status == "normal", JudgeServer.objects.all()))) + return self.success({ + "user_count": User.objects.count(), + "recent_contest_count": recent_contest_count, + "today_submission_count": today_submission_count, + "judge_server_count": judge_server_count, + "env": { + "FORCE_HTTPS": get_env("FORCE_HTTPS", default=False), + "STATIC_CDN_HOST": get_env("STATIC_CDN_HOST", default="") + } + }) diff --git a/contest/views/admin.py b/contest/views/admin.py index d4066b51..a44e9da9 100644 --- a/contest/views/admin.py +++ b/contest/views/admin.py @@ -5,7 +5,7 @@ from utils.api import APIView, validate_serializer from utils.cache import cache from utils.constants import CacheKey -from account.decorators import check_contest_permission +from account.decorators import check_contest_permission, ensure_created_by from ..models import Contest, ContestAnnouncement, ACMContestRank from ..serializers import (ContestAnnouncementSerializer, ContestAdminSerializer, CreateConetestSeriaizer, CreateContestAnnouncementSerializer, @@ -37,8 +37,7 @@ class ContestAPI(APIView): data = request.data try: contest = Contest.objects.get(id=data.pop("id")) - if request.user.is_admin() and contest.created_by != request.user: - return self.error("Contest does not exist") + ensure_created_by(contest, request.user) except Contest.DoesNotExist: return self.error("Contest does not exist") data["start_time"] = dateutil.parser.parse(data["start_time"]) @@ -66,20 +65,18 @@ class ContestAPI(APIView): if contest_id: try: contest = Contest.objects.get(id=contest_id) - if request.user.is_admin() and contest.created_by != request.user: - return self.error("Contest does not exist") + ensure_created_by(contest, request.user) return self.success(ContestAdminSerializer(contest).data) except Contest.DoesNotExist: return self.error("Contest does not exist") contests = Contest.objects.all().order_by("-create_time") + if request.user.is_admin(): + contests = contests.filter(created_by=request.user) keyword = request.GET.get("keyword") if keyword: contests = contests.filter(title__contains=keyword) - - if request.user.is_admin(): - contests = contests.filter(created_by=request.user) return self.success(self.paginate_data(request, contests, ContestAdminSerializer)) @@ -92,8 +89,7 @@ class ContestAnnouncementAPI(APIView): data = request.data try: contest = Contest.objects.get(id=data.pop("contest_id")) - if request.user.is_admin() and contest.created_by != request.user: - return self.error("Contest does not exist") + ensure_created_by(contest, request.user) data["contest"] = contest data["created_by"] = request.user except Contest.DoesNotExist: @@ -109,8 +105,7 @@ class ContestAnnouncementAPI(APIView): data = request.data try: contest_announcement = ContestAnnouncement.objects.get(id=data.pop("id")) - if request.user.is_admin() and contest_announcement.created_by != request.user: - return self.error("Contest announcement does not exist") + ensure_created_by(contest_announcement, request.user) except ContestAnnouncement.DoesNotExist: return self.error("Contest announcement does not exist") for k, v in data.items(): @@ -139,15 +134,14 @@ class ContestAnnouncementAPI(APIView): if contest_announcement_id: try: contest_announcement = ContestAnnouncement.objects.get(id=contest_announcement_id) - if request.user.is_admin() and contest_announcement.created_by != request.user: - return self.error("Contest announcement does not exist") + ensure_created_by(contest_announcement, request.user) return self.success(ContestAnnouncementSerializer(contest_announcement).data) except ContestAnnouncement.DoesNotExist: return self.error("Contest announcement does not exist") contest_id = request.GET.get("contest_id") if not contest_id: - return self.error("Paramater error") + return self.error("Parameter error") contest_announcements = ContestAnnouncement.objects.filter(contest_id=contest_id) if request.user.is_admin(): contest_announcements = contest_announcements.filter(created_by=request.user) @@ -177,12 +171,10 @@ class ACMContestHelper(APIView): results.sort(key=lambda x: -x["ac_info"]["ac_time"]) return self.success(results) - @validate_serializer(ACMContesHelperSerializer) @check_contest_permission(check_type="ranks") + @validate_serializer(ACMContesHelperSerializer) def put(self, request): data = request.data - if not request.user.is_contest_admin(self.contest): - return self.error("You are not contest admin") try: rank = ACMContestRank.objects.get(pk=data["rank_id"]) except ACMContestRank.DoesNotExist: diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh index 37bf799e..929e9c0b 100755 --- a/deploy/entrypoint.sh +++ b/deploy/entrypoint.sh @@ -31,10 +31,17 @@ else ln -sf https_redirect.conf http_locations.conf fi +if [ ! -z "$LOWER_IP_HEADER" ]; then + sed -i "s/__IP_HEADER__/\$http_$LOWER_IP_HEADER/g" api_proxy.conf; +else + sed -i "s/__IP_HEADER__/\$remote_addr/g" api_proxy.conf; +fi + cd $APP/dist if [ ! -z "$STATIC_CDN_HOST" ]; then - find . -name index.html -exec sed -i "s/link href=\/static/link href=\/\/$STATIC_CDN_HOST\/static/g" {} \; - find . -name index.html -exec sed -i "s/script type=text\/javascript src=\/static/script type=text\/javascript src=\/\/$STATIC_CDN_HOST\/static/g" {} \; + find . -name index.html -exec sed -i "s/__STATIC_CDN_HOST__/\/\/$STATIC_CDN_HOST/g" {} \; +else + find . -name index.html -exec sed -i "s/__STATIC_CDN_HOST__//g" {} \; fi cd $APP @@ -44,13 +51,13 @@ while [ $n -lt 5 ] do python manage.py migrate --no-input && python manage.py inituser --username=root --password=rootroot --action=create_super_admin && + echo "from options.options import SysOptions; SysOptions.judge_server_token='$JUDGE_SERVER_TOKEN'" | python manage.py shell && break n=$(($n+1)) echo "Failed to migrate, going to retry..." sleep 8 done -echo "from options.options import SysOptions; SysOptions.judge_server_token='$JUDGE_SERVER_TOKEN'" | python manage.py shell || exit 1 chown -R nobody:nogroup $DATA $APP/dist -exec supervisord -c /app/deploy/supervisord.conf \ No newline at end of file +exec supervisord -c /app/deploy/supervisord.conf diff --git a/deploy/nginx/api_proxy.conf b/deploy/nginx/api_proxy.conf new file mode 100644 index 00000000..631103d9 --- /dev/null +++ b/deploy/nginx/api_proxy.conf @@ -0,0 +1,5 @@ +proxy_pass http://backend; +proxy_set_header X-Real-IP __IP_HEADER__; +proxy_set_header Host $http_host;client_max_body_size 200M; +proxy_http_version 1.1; +proxy_set_header Connection ''; \ No newline at end of file diff --git a/deploy/nginx/https_redirect.conf b/deploy/nginx/https_redirect.conf index 71eb6c0d..88d81036 100644 --- a/deploy/nginx/https_redirect.conf +++ b/deploy/nginx/https_redirect.conf @@ -1,3 +1,7 @@ +location /api/judge_server_heartbeat { + include api_proxy.conf; +} + location / { return 301 https://$host$request_uri; } \ No newline at end of file diff --git a/deploy/nginx/locations.conf b/deploy/nginx/locations.conf index 1732c7e7..a096ffa6 100644 --- a/deploy/nginx/locations.conf +++ b/deploy/nginx/locations.conf @@ -3,10 +3,7 @@ location /public { } location /api { - proxy_pass http://backend; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header Host $http_host; - client_max_body_size 200M; + include api_proxy.conf; } location /data/ { diff --git a/docs/data.json b/docs/data.json index 53d164a9..415fa28c 100644 --- a/docs/data.json +++ b/docs/data.json @@ -1,14 +1,15 @@ { "update": [ { - "version": "2017-12-25", - "level": 1, - "title": "Update at 2017-12-25", + "version": "2017-01-04", + "level": "Recommend", + "title": "Update at 2018-01-04", "details": [ "Fix some issues under IE/Edge", "Add backend error reporter", "New email template", "A more flexible throttling function", + "Add admin dashboard (this page)", "Other bugs and enhancements" ] } diff --git a/oj/production_settings.py b/oj/production_settings.py index 026c53a9..e1bcde80 100644 --- a/oj/production_settings.py +++ b/oj/production_settings.py @@ -1,9 +1,4 @@ -import os - - -def get_env(name, default=""): - return os.environ.get(name, default) - +from utils.shortcuts import get_env DATABASES = { 'default': { diff --git a/oj/settings.py b/oj/settings.py index 2cb64ad2..055c0558 100644 --- a/oj/settings.py +++ b/oj/settings.py @@ -12,14 +12,15 @@ https://docs.djangoproject.com/en/1.8/ref/settings/ import os import raven from copy import deepcopy +from utils.shortcuts import get_env +from .custom_settings import * -if os.environ.get("OJ_ENV") == "production": +production_env = get_env("OJ_ENV", "dev") == "production" +if production_env: from .production_settings import * else: from .dev_settings import * -from .custom_settings import * - BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Applications @@ -128,7 +129,7 @@ UPLOAD_DIR = f"{DATA_DIR}{UPLOAD_PREFIX}" STATICFILES_DIRS = [os.path.join(DATA_DIR, "public")] -LOGGING_HANDLERS = ['console'] if DEBUG else ['console', 'sentry'] +LOGGING_HANDLERS = ['console', 'sentry'] if production_env else ['console'] LOGGING = { 'version': 1, 'disable_existing_loggers': False, @@ -204,13 +205,6 @@ BROKER_URL = f"{REDIS_URL}/3" CELERY_TASK_SOFT_TIME_LIMIT = CELERY_TASK_TIME_LIMIT = 180 CELERY_ACCEPT_CONTENT = ["json"] CELERY_TASK_SERIALIZER = "json" - -# 用于限制用户恶意提交大量代码 -TOKEN_BUCKET_DEFAULT_CAPACITY = 10 - -# 单位:每分钟 -TOKEN_BUCKET_FILL_RATE = 2 - RAVEN_CONFIG = { 'dsn': 'https://b200023b8aed4d708fb593c5e0a6ad3d:1fddaba168f84fcf97e0d549faaeaff0@sentry.io/263057' } \ No newline at end of file diff --git a/problem/views/admin.py b/problem/views/admin.py index 352aa095..5e8a880d 100644 --- a/problem/views/admin.py +++ b/problem/views/admin.py @@ -8,7 +8,7 @@ from wsgiref.util import FileWrapper from django.conf import settings from django.http import StreamingHttpResponse, HttpResponse -from account.decorators import problem_permission_required +from account.decorators import problem_permission_required, ensure_created_by from judge.dispatcher import SPJCompiler from contest.models import Contest, ContestStatus from submission.models import Submission @@ -49,7 +49,6 @@ class TestCaseAPI(CSRFExemptAPIView): else: return sorted(ret, key=natural_sort_key) - @problem_permission_required def get(self, request): problem_id = request.GET.get("problem_id") if not problem_id: @@ -59,6 +58,11 @@ class TestCaseAPI(CSRFExemptAPIView): except Problem.DoesNotExist: return self.error("Problem does not exists") + if problem.contest: + ensure_created_by(problem.contest, request.user) + else: + ensure_created_by(problem, request.user) + test_case_dir = os.path.join(settings.TEST_CASE_DIR, problem.test_case_id) if not os.path.isdir(test_case_dir): return self.error("Test case does not exists") @@ -79,7 +83,6 @@ class TestCaseAPI(CSRFExemptAPIView): response["Content-Length"] = os.path.getsize(file_name) return response - @problem_permission_required def post(self, request): form = TestCaseUploadForm(request.POST, request.FILES) if form.is_valid(): @@ -147,7 +150,6 @@ class TestCaseAPI(CSRFExemptAPIView): class CompileSPJAPI(APIView): @validate_serializer(CompileSPJSerializer) - @problem_permission_required def post(self, request): data = request.data spj_version = rand_str(8) @@ -186,11 +188,12 @@ class ProblemBase(APIView): def delete(self, request): id = request.GET.get("id") if not id: - return self.error("Invalid parameter, id is requred") + return self.error("Invalid parameter, id is required") try: - problem = Problem.objects.get(id=id) + problem = Problem.objects.get(id=id, contest_id__isnull=True) except Problem.DoesNotExist: return self.error("Problem does not exists") + ensure_created_by(problem, request.user) if Submission.objects.filter(problem=problem).exists(): return self.error("Can't delete the problem as it has submissions") d = os.path.join(settings.TEST_CASE_DIR, problem.test_case_id) @@ -201,11 +204,10 @@ class ProblemBase(APIView): class ProblemAPI(ProblemBase): - @validate_serializer(CreateProblemSerializer) @problem_permission_required + @validate_serializer(CreateProblemSerializer) def post(self, request): data = request.data - _id = data["_id"] if not _id: return self.error("Display ID is required") @@ -236,8 +238,7 @@ class ProblemAPI(ProblemBase): if problem_id: try: problem = Problem.objects.get(id=problem_id) - if not user.can_mgmt_all_problem() and problem.created_by != user: - return self.error("Problem does not exist") + ensure_created_by(problem, request.user) return self.success(ProblemAdminSerializer(problem).data) except Problem.DoesNotExist: return self.error("Problem does not exist") @@ -256,17 +257,15 @@ class ProblemAPI(ProblemBase): problems = problems.filter(title__contains=keyword) return self.success(self.paginate_data(request, problems, ProblemAdminSerializer)) - @validate_serializer(EditProblemSerializer) @problem_permission_required + @validate_serializer(EditProblemSerializer) def put(self, request): data = request.data problem_id = data.pop("id") - user = request.user try: problem = Problem.objects.get(id=problem_id) - if not user.can_mgmt_all_problem() and problem.created_by != user: - return self.error("Problem does not exist") + ensure_created_by(problem, request.user) except Problem.DoesNotExist: return self.error("Problem does not exist") @@ -300,13 +299,11 @@ class ProblemAPI(ProblemBase): class ContestProblemAPI(ProblemBase): @validate_serializer(CreateContestProblemSerializer) - @problem_permission_required def post(self, request): data = request.data try: contest = Contest.objects.get(id=data.pop("contest_id")) - if request.user.is_admin() and contest.created_by != request.user: - return self.error("Contest does not exist") + ensure_created_by(contest, request.user) except Contest.DoesNotExist: return self.error("Contest does not exist") @@ -345,8 +342,7 @@ class ContestProblemAPI(ProblemBase): if problem_id: try: problem = Problem.objects.get(id=problem_id) - if user.is_admin() and problem.contest.created_by != user: - return self.error("Problem does not exist") + ensure_created_by(problem, user) except Problem.DoesNotExist: return self.error("Problem does not exist") return self.success(ProblemAdminSerializer(problem).data) @@ -366,10 +362,11 @@ class ContestProblemAPI(ProblemBase): @problem_permission_required def put(self, request): data = request.data + user = request.user + try: contest = Contest.objects.get(id=data.pop("contest_id")) - if request.user.is_admin() and contest.created_by != request.user: - return self.error("Contest does not exist") + ensure_created_by(contest, user) except Contest.DoesNotExist: return self.error("Contest does not exist") @@ -377,12 +374,10 @@ class ContestProblemAPI(ProblemBase): return self.error("Invalid rule type") problem_id = data.pop("id") - user = request.user try: problem = Problem.objects.get(id=problem_id) - if not user.can_mgmt_all_problem() and problem.created_by != user: - return self.error("Problem does not exist") + ensure_created_by(problem, user) except Problem.DoesNotExist: return self.error("Problem does not exist") @@ -428,7 +423,7 @@ class MakeContestProblemPublicAPIView(APIView): return self.error("Problem does not exist") if not problem.contest or problem.is_public: - return self.error("Alreay be a public problem") + return self.error("Already be a public problem") problem.is_public = True problem.save() # https://docs.djangoproject.com/en/1.11/topics/db/queries/#copying-model-instances diff --git a/utils/api/api.py b/utils/api/api.py index e33daafb..5b7a2316 100644 --- a/utils/api/api.py +++ b/utils/api/api.py @@ -11,6 +11,13 @@ from django.views.generic import View logger = logging.getLogger("") +class APIError(Exception): + def __init__(self, msg, err=None): + self.err = err + self.msg = msg + super().__init__(err, msg) + + class ContentType(object): json_request = "application/json" json_response = "application/json;charset=UTF-8" @@ -137,6 +144,11 @@ class APIView(View): return self.error(err="invalid-request", msg=str(e)) try: return super(APIView, self).dispatch(request, *args, **kwargs) + except APIError as e: + ret = {"msg": e.msg} + if e.err: + ret["err"] = e.err + return self.error(**ret) except Exception as e: logger.exception(e) return self.server_error() diff --git a/utils/shortcuts.py b/utils/shortcuts.py index 111c31b8..ea0d094f 100644 --- a/utils/shortcuts.py +++ b/utils/shortcuts.py @@ -1,3 +1,4 @@ +import os import re import datetime import random @@ -76,3 +77,7 @@ def send_email(smtp_config, from_name, to_email, to_name, subject, content): password=smtp_config["password"], port=smtp_config["port"], tls=smtp_config["tls"]) + + +def get_env(name, default=""): + return os.environ.get(name, default)