Skip to content

Commit 167f47c

Browse files
authored
Merge branch 'main' into remove-codowners
2 parents da3909a + 62341a8 commit 167f47c

17 files changed

Lines changed: 411 additions & 49 deletions

File tree

cms/db/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181

8282
# Instantiate or import these objects.
8383

84-
version = 44
84+
version = 45
8585

8686
engine = create_engine(config.database, echo=config.database_debug,
8787
pool_timeout=60, pool_recycle=120)

cms/db/submission.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"""
2828

2929
from datetime import datetime
30+
import random
3031
from sqlalchemy import Boolean
3132
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
3233
from sqlalchemy.orm import relationship
@@ -46,6 +47,15 @@ class Submission(Base):
4647
4748
"""
4849
__tablename__ = 'submissions'
50+
__table_args__ = (
51+
UniqueConstraint("participation_id", "opaque_id",
52+
name="participation_opaque_unique"),
53+
)
54+
55+
# Opaque ID to be used to refer to this submission.
56+
opaque_id: int = Column(
57+
BigInteger,
58+
nullable=False)
4959

5060
# Auto increment primary key.
5161
id: int = Column(
@@ -177,6 +187,25 @@ def tokened(self) -> bool:
177187
"""
178188
return self.token is not None
179189

190+
@classmethod
191+
def generate_opaque_id(cls, session, participation_id):
192+
randint_upper_bound = 2**63-1
193+
194+
opaque_id = random.randint(0, randint_upper_bound)
195+
196+
# Note that in theory this may cause the transaction to fail by
197+
# generating a non-actually-unique ID. This is however extremely
198+
# unlikely (prob. ~num_parallel_submissions_per_contestant^2/2**63).
199+
while (session
200+
.query(Submission)
201+
.filter(Submission.participation_id == participation_id)
202+
.filter(Submission.opaque_id == opaque_id)
203+
.first()
204+
is not None):
205+
opaque_id = random.randint(0, randint_upper_bound)
206+
207+
return opaque_id
208+
180209

181210
class File(Base):
182211
"""Class to store information about one file submitted within a

cms/server/contest/handlers/__init__.py

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,24 @@
2323
# You should have received a copy of the GNU Affero General Public License
2424
# along with this program. If not, see <http://www.gnu.org/licenses/>.
2525

26-
from .communication import \
27-
CommunicationHandler, \
28-
QuestionHandler
26+
from .taskusertest import \
27+
UserTestInterfaceHandler, \
28+
UserTestHandler, \
29+
UserTestStatusHandler, \
30+
UserTestDetailsHandler, \
31+
UserTestIOHandler, \
32+
UserTestFileHandler
33+
from .tasksubmission import \
34+
SubmitHandler, \
35+
TaskSubmissionsHandler, \
36+
SubmissionStatusHandler, \
37+
SubmissionDetailsHandler, \
38+
SubmissionFileHandler, \
39+
UseTokenHandler
40+
from .task import \
41+
TaskDescriptionHandler, \
42+
TaskStatementViewHandler, \
43+
TaskAttachmentViewHandler
2944
from .main import \
3045
LoginHandler, \
3146
LogoutHandler, \
@@ -34,24 +49,14 @@
3449
NotificationsHandler, \
3550
PrintingHandler, \
3651
DocumentationHandler
37-
from .task import \
38-
TaskDescriptionHandler, \
39-
TaskStatementViewHandler, \
40-
TaskAttachmentViewHandler
41-
from .tasksubmission import \
42-
SubmitHandler, \
43-
TaskSubmissionsHandler, \
44-
SubmissionStatusHandler, \
45-
SubmissionDetailsHandler, \
46-
SubmissionFileHandler, \
47-
UseTokenHandler
48-
from .taskusertest import \
49-
UserTestInterfaceHandler, \
50-
UserTestHandler, \
51-
UserTestStatusHandler, \
52-
UserTestDetailsHandler, \
53-
UserTestIOHandler, \
54-
UserTestFileHandler
52+
from .communication import \
53+
CommunicationHandler, \
54+
QuestionHandler
55+
from .api import \
56+
ApiLoginHandler, \
57+
ApiSubmissionListHandler, \
58+
ApiSubmitHandler, \
59+
ApiTaskListHandler
5560

5661

5762
HANDLERS = [
@@ -97,6 +102,12 @@
97102
(r"/communication", CommunicationHandler),
98103
(r"/question", QuestionHandler),
99104

105+
# API
106+
(r"/api/login", ApiLoginHandler),
107+
(r"/api/task_list", ApiTaskListHandler),
108+
(r"/api/(.*)/submit", ApiSubmitHandler),
109+
(r"/api/(.*)/submission_list", ApiSubmissionListHandler),
110+
100111
# The following prefixes are handled by WSGI middlewares:
101112
# * /static, defined in cms/io/web_service.py
102113
# * /docs, defined in cms/server/contest/server.py

cms/server/contest/handlers/api.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
#!/usr/bin/env python3
2+
3+
# Contest Management System - http://cms-dev.github.io/
4+
# Copyright © 2025 Luca Versari <veluca93@gmail.com>
5+
#
6+
# This program is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU Affero General Public License as
8+
# published by the Free Software Foundation, either version 3 of the
9+
# License, or (at your option) any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU Affero General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU Affero General Public License
17+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
18+
19+
"""API handlers for CMS.
20+
21+
"""
22+
23+
import ipaddress
24+
import logging
25+
26+
try:
27+
import tornado4.web as tornado_web
28+
except ImportError:
29+
import tornado.web as tornado_web
30+
31+
from cms.db.submission import Submission
32+
from cms.server import multi_contest
33+
from cms.server.contest.authentication import validate_login
34+
from cms.server.contest.submission import \
35+
UnacceptableSubmission, accept_submission
36+
from .contest import ContestHandler
37+
from ..phase_management import actual_phase_required
38+
39+
logger = logging.getLogger(__name__)
40+
41+
42+
class ApiLoginHandler(ContestHandler):
43+
"""Login handler.
44+
45+
"""
46+
@multi_contest
47+
def post(self):
48+
username = self.get_argument("username", "")
49+
password = self.get_argument("password", "")
50+
51+
try:
52+
ip_address = ipaddress.ip_address(self.request.remote_ip)
53+
except ValueError:
54+
logger.warning("Invalid IP address provided by Tornado: %s",
55+
self.request.remote_ip)
56+
return None
57+
58+
participation, login_data = validate_login(
59+
self.sql_session, self.contest, self.timestamp, username, password,
60+
ip_address)
61+
62+
if participation is None:
63+
self.json({"error": "Login failed"}, 403)
64+
elif login_data is not None:
65+
cookie_name = self.contest.name + "_login"
66+
self.json({"login_data": self.create_signed_value(
67+
cookie_name, login_data).decode()})
68+
else:
69+
self.json({})
70+
71+
def check_xsrf_cookie(self):
72+
pass
73+
74+
75+
class ApiTaskListHandler(ContestHandler):
76+
"""Handler to list all tasks and their statements.
77+
78+
"""
79+
@tornado_web.authenticated
80+
@actual_phase_required(0, 3)
81+
@multi_contest
82+
def get(self):
83+
contest = self.contest
84+
tasks = []
85+
for task in contest.tasks:
86+
name = task.name
87+
statements = [s for s in task.statements]
88+
sub_format = task.submission_format
89+
tasks.append({"name": name,
90+
"statements": statements,
91+
"submission_format": sub_format})
92+
self.json({"tasks": tasks})
93+
94+
95+
class ApiSubmitHandler(ContestHandler):
96+
"""Handles the received submissions.
97+
98+
"""
99+
@tornado_web.authenticated
100+
@actual_phase_required(0, 3)
101+
@multi_contest
102+
def post(self, task_name: str):
103+
task = self.get_task(task_name)
104+
if task is None:
105+
self.json({"error": "Not found"}, 404)
106+
return
107+
108+
# Only set the official bit when the user can compete and we are not in
109+
# analysis mode.
110+
official = self.r_params["actual_phase"] == 0
111+
112+
try:
113+
submission = accept_submission(
114+
self.sql_session, self.service.file_cacher, self.current_user,
115+
task, self.timestamp, self.request.files,
116+
self.get_argument("language", None), official)
117+
self.sql_session.commit()
118+
except UnacceptableSubmission as e:
119+
logger.info("API submission rejected: `%s' - `%s'",
120+
e.subject, e.formatted_text)
121+
self.json({"error": e.subject, "details": e.formatted_text}, 400)
122+
else:
123+
logger.info(
124+
f'API submission accepted: Submission ID {submission.id}')
125+
self.service.evaluation_service.new_submission(
126+
submission_id=submission.id)
127+
self.json({'id': str(submission.opaque_id)})
128+
129+
130+
class ApiSubmissionListHandler(ContestHandler):
131+
"""Retrieves the list of submissions on a task.
132+
133+
"""
134+
@tornado_web.authenticated
135+
@actual_phase_required(0, 3)
136+
@multi_contest
137+
def get(self, task_name: str):
138+
task = self.get_task(task_name)
139+
if task is None:
140+
self.json({"error": "Not found"}, 404)
141+
return
142+
submissions: list[Submission] = (
143+
self.sql_session.query(Submission)
144+
.filter(Submission.participation == self.current_user)
145+
.filter(Submission.task == task)
146+
.all()
147+
)
148+
self.json({'list': [{"id": str(s.opaque_id)} for s in submissions]})

cms/server/contest/handlers/contest.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"""
3131

3232
import ipaddress
33+
import json
3334
import logging
3435

3536
import collections
@@ -159,6 +160,11 @@ def get_current_user(self) -> Participation | None:
159160
"""
160161
cookie_name = self.contest.name + "_login"
161162
cookie = self.get_secure_cookie(cookie_name)
163+
authorization_header = self.request.headers.get(
164+
"X-CMS-Authorization", None)
165+
if authorization_header is not None:
166+
authorization_header = tornado_web.decode_signed_value(self.application.settings["cookie_secret"],
167+
cookie_name, authorization_header)
162168

163169
try:
164170
ip_address = ipaddress.ip_address(self.request.remote_ip)
@@ -170,7 +176,7 @@ def get_current_user(self) -> Participation | None:
170176
participation, cookie = authenticate_request(
171177
self.sql_session, self.contest,
172178
self.timestamp, cookie,
173-
self.request.headers.get("X-CMS-Authorization", None),
179+
authorization_header,
174180
ip_address)
175181

176182
if cookie is None:
@@ -250,7 +256,7 @@ def get_task(self, task_name: str) -> Task | None:
250256
.filter(Task.name == task_name) \
251257
.one_or_none()
252258

253-
def get_submission(self, task: Task, submission_num: str) -> Submission | None:
259+
def get_submission(self, task: Task, opaque_id: str | int) -> Submission | None:
254260
"""Return the num-th contestant's submission on the given task.
255261
256262
task: a task for the contest that is being served.
@@ -265,8 +271,7 @@ def get_submission(self, task: Task, submission_num: str) -> Submission | None:
265271
return self.sql_session.query(Submission) \
266272
.filter(Submission.participation == self.current_user) \
267273
.filter(Submission.task == task) \
268-
.order_by(Submission.timestamp) \
269-
.offset(int(submission_num) - 1) \
274+
.filter(Submission.opaque_id == int(opaque_id)) \
270275
.first()
271276

272277
def get_user_test(self, task: Task, user_test_num: int) -> UserTest | None:
@@ -310,6 +315,19 @@ def notify_warning(
310315
def notify_error(self, subject: str, text: str, text_params: object | None = None):
311316
self.add_notification(subject, text, NOTIFICATION_ERROR, text_params)
312317

318+
def json(self, data, status_code=200):
319+
self.set_header("Content-type", "application/json; charset=utf-8")
320+
self.set_status(status_code)
321+
self.write(json.dumps(data))
322+
323+
def check_xsrf_cookie(self):
324+
# We don't need to check for xsrf if the request came with a custom
325+
# header, as those are not set by the browser.
326+
if "X-CMS-Authorization" in self.request.headers:
327+
pass
328+
else:
329+
super().check_xsrf_cookie()
330+
313331

314332
class FileHandler(ContestHandler, FileHandlerMixin):
315333
pass

0 commit comments

Comments
 (0)