RankingWebServer-backup.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  1. #!/usr/bin/env python3
  2. # Contest Management System - http://cms-dev.github.io/
  3. # Copyright © 2011-2017 Luca Wehrstedt <luca.wehrstedt@gmail.com>
  4. #
  5. # This program is free software: you can redistribute it and/or modify
  6. # it under the terms of the GNU Affero General Public License as
  7. # published by the Free Software Foundation, either version 3 of the
  8. # License, or (at your option) any later version.
  9. #
  10. # This program is distributed in the hope that it will be useful,
  11. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. # GNU Affero General Public License for more details.
  14. #
  15. # You should have received a copy of the GNU Affero General Public License
  16. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  17. import argparse
  18. import functools
  19. import json
  20. import logging
  21. import os
  22. import pprint
  23. import re
  24. import shutil
  25. import time
  26. from datetime import datetime
  27. import gevent
  28. from gevent.pywsgi import WSGIServer
  29. from werkzeug.exceptions import HTTPException, BadRequest, Unauthorized, \
  30. Forbidden, NotFound, NotAcceptable, UnsupportedMediaType
  31. from werkzeug.routing import Map, Rule
  32. from werkzeug.wrappers import Request, Response
  33. from werkzeug.wsgi import responder, wrap_file, SharedDataMiddleware, \
  34. DispatcherMiddleware
  35. # Needed for initialization. Do not remove.
  36. import cmsranking.Logger # noqa
  37. from cmscommon.eventsource import EventSource
  38. from cmsranking.Config import Config
  39. from cmsranking.Contest import Contest
  40. from cmsranking.Entity import InvalidData
  41. from cmsranking.Scoring import ScoringStore
  42. from cmsranking.Store import Store
  43. from cmsranking.Subchange import Subchange
  44. from cmsranking.Submission import Submission
  45. from cmsranking.Task import Task
  46. from cmsranking.Team import Team
  47. from cmsranking.User import User
  48. logger = logging.getLogger(__name__)
  49. class CustomUnauthorized(Unauthorized):
  50. def __init__(self, realm_name):
  51. super().__init__()
  52. self.realm_name = realm_name
  53. def get_response(self, environ=None):
  54. response = super().get_response(environ)
  55. # XXX With werkzeug-0.9 a full-featured Response object is
  56. # returned: there is no need for this.
  57. response = Response.force_type(response)
  58. response.www_authenticate.set_basic(self.realm_name)
  59. return response
  60. class StoreHandler:
  61. def __init__(self, store, username, password, realm_name):
  62. self.store = store
  63. self.username = username
  64. self.password = password
  65. self.realm_name = realm_name
  66. self.router = Map([
  67. Rule("/<key>", methods=["GET"], endpoint="get"),
  68. Rule("/", methods=["GET"], endpoint="get_list"),
  69. Rule("/<key>", methods=["PUT"], endpoint="put"),
  70. Rule("/", methods=["PUT"], endpoint="put_list"),
  71. Rule("/<key>", methods=["DELETE"], endpoint="delete"),
  72. Rule("/", methods=["DELETE"], endpoint="delete_list"),
  73. ], encoding_errors="strict")
  74. def __call__(self, environ, start_response):
  75. return self.wsgi_app(environ, start_response)
  76. @responder
  77. def wsgi_app(self, environ, start_response):
  78. route = self.router.bind_to_environ(environ)
  79. try:
  80. endpoint, args = route.match()
  81. except HTTPException as exc:
  82. return exc
  83. request = Request(environ)
  84. request.encoding_errors = "strict"
  85. response = Response()
  86. try:
  87. if endpoint == "get":
  88. self.get(request, response, args["key"])
  89. elif endpoint == "get_list":
  90. self.get_list(request, response)
  91. elif endpoint == "put":
  92. self.put(request, response, args["key"])
  93. elif endpoint == "put_list":
  94. self.put_list(request, response)
  95. elif endpoint == "delete":
  96. self.delete(request, response, args["key"])
  97. elif endpoint == "delete_list":
  98. self.delete_list(request, response)
  99. else:
  100. raise RuntimeError()
  101. except HTTPException as exc:
  102. return exc
  103. return response
  104. def authorized(self, request):
  105. return request.authorization is not None and \
  106. request.authorization.type == "basic" and \
  107. request.authorization.username == self.username and \
  108. request.authorization.password == self.password
  109. def get(self, request, response, key):
  110. # Limit charset of keys.
  111. if re.match("^[A-Za-z0-9_]+$", key) is None:
  112. return NotFound()
  113. if key not in self.store:
  114. raise NotFound()
  115. response.status_code = 200
  116. response.headers['Timestamp'] = "%0.6f" % time.time()
  117. response.mimetype = "application/json"
  118. response.data = json.dumps(self.store.retrieve(key))
  119. def get_list(self, request, response):
  120. response.status_code = 200
  121. response.headers['Timestamp'] = "%0.6f" % time.time()
  122. response.mimetype = "application/json"
  123. response.data = json.dumps(self.store.retrieve_list())
  124. def put(self, request, response, key):
  125. # Limit charset of keys.
  126. if re.match("^[A-Za-z0-9_]+$", key) is None:
  127. return Forbidden()
  128. if not self.authorized(request):
  129. logger.warning("Unauthorized request.",
  130. extra={'location': request.url,
  131. 'details': repr(request.authorization)})
  132. raise CustomUnauthorized(self.realm_name)
  133. if request.mimetype != "application/json":
  134. logger.warning("Unsupported MIME type.",
  135. extra={'location': request.url,
  136. 'details': request.mimetype})
  137. raise UnsupportedMediaType()
  138. try:
  139. data = json.load(request.stream)
  140. except (TypeError, ValueError):
  141. logger.warning("Wrong JSON.",
  142. extra={'location': request.url})
  143. raise BadRequest()
  144. try:
  145. if key not in self.store:
  146. self.store.create(key, data)
  147. else:
  148. self.store.update(key, data)
  149. except InvalidData as err:
  150. logger.warning("Invalid data: %s" % str(err), exc_info=False,
  151. extra={'location': request.url,
  152. 'details': pprint.pformat(data)})
  153. raise BadRequest()
  154. response.status_code = 204
  155. def put_list(self, request, response):
  156. if not self.authorized(request):
  157. logger.info("Unauthorized request.",
  158. extra={'location': request.url,
  159. 'details': repr(request.authorization)})
  160. raise CustomUnauthorized(self.realm_name)
  161. if request.mimetype != "application/json":
  162. logger.warning("Unsupported MIME type.",
  163. extra={'location': request.url,
  164. 'details': request.mimetype})
  165. raise UnsupportedMediaType()
  166. try:
  167. data = json.load(request.stream)
  168. except (TypeError, ValueError):
  169. logger.warning("Wrong JSON.",
  170. extra={'location': request.url})
  171. raise BadRequest()
  172. try:
  173. self.store.merge_list(data)
  174. except InvalidData as err:
  175. logger.warning("Invalid data: %s" % str(err), exc_info=False,
  176. extra={'location': request.url,
  177. 'details': pprint.pformat(data)})
  178. raise BadRequest()
  179. response.status_code = 204
  180. def delete(self, request, response, key):
  181. # Limit charset of keys.
  182. if re.match("^[A-Za-z0-9_]+$", key) is None:
  183. return NotFound()
  184. if key not in self.store:
  185. raise NotFound()
  186. if not self.authorized(request):
  187. logger.info("Unauthorized request.",
  188. extra={'location': request.url,
  189. 'details': repr(request.authorization)})
  190. raise CustomUnauthorized(self.realm_name)
  191. self.store.delete(key)
  192. response.status_code = 204
  193. def delete_list(self, request, response):
  194. if not self.authorized(request):
  195. logger.info("Unauthorized request.",
  196. extra={'location': request.url,
  197. 'details': repr(request.authorization)})
  198. raise CustomUnauthorized(self.realm_name)
  199. self.store.delete_list()
  200. response.status_code = 204
  201. class DataWatcher(EventSource):
  202. """Receive the messages from the entities store and redirect them."""
  203. def __init__(self, stores, buffer_size):
  204. self._CACHE_SIZE = buffer_size
  205. EventSource.__init__(self)
  206. stores["contest"].add_create_callback(
  207. functools.partial(self.callback, "contest", "create"))
  208. stores["contest"].add_update_callback(
  209. functools.partial(self.callback, "contest", "update"))
  210. stores["contest"].add_delete_callback(
  211. functools.partial(self.callback, "contest", "delete"))
  212. stores["task"].add_create_callback(
  213. functools.partial(self.callback, "task", "create"))
  214. stores["task"].add_update_callback(
  215. functools.partial(self.callback, "task", "update"))
  216. stores["task"].add_delete_callback(
  217. functools.partial(self.callback, "task", "delete"))
  218. stores["team"].add_create_callback(
  219. functools.partial(self.callback, "team", "create"))
  220. stores["team"].add_update_callback(
  221. functools.partial(self.callback, "team", "update"))
  222. stores["team"].add_delete_callback(
  223. functools.partial(self.callback, "team", "delete"))
  224. stores["user"].add_create_callback(
  225. functools.partial(self.callback, "user", "create"))
  226. stores["user"].add_update_callback(
  227. functools.partial(self.callback, "user", "update"))
  228. stores["user"].add_delete_callback(
  229. functools.partial(self.callback, "user", "delete"))
  230. stores["scoring"].add_score_callback(self.score_callback)
  231. def callback(self, entity, event, key, *args):
  232. self.send(entity, "%s %s" % (event, key))
  233. def score_callback(self, user, task, score):
  234. # FIXME Use score_precision.
  235. self.send("score", "%s %s %0.2f" % (user, task, score))
  236. class SubListHandler:
  237. def __init__(self, stores):
  238. self.task_store = stores["task"]
  239. self.scoring_store = stores["scoring"]
  240. self.router = Map([
  241. Rule("/<user_id>", methods=["GET"], endpoint="sublist"),
  242. ], encoding_errors="strict")
  243. def __call__(self, environ, start_response):
  244. return self.wsgi_app(environ, start_response)
  245. def wsgi_app(self, environ, start_response):
  246. route = self.router.bind_to_environ(environ)
  247. try:
  248. endpoint, args = route.match()
  249. except HTTPException as exc:
  250. return exc(environ, start_response)
  251. assert endpoint == "sublist"
  252. request = Request(environ)
  253. request.encoding_errors = "strict"
  254. if request.accept_mimetypes.quality("application/json") <= 0:
  255. raise NotAcceptable()
  256. result = list()
  257. for task_id in self.task_store._store.keys():
  258. result.extend(
  259. self.scoring_store.get_submissions(
  260. args["user_id"], task_id
  261. ).values()
  262. )
  263. result.sort(key= (x.task, x.time))
  264. result = list(a.__dict__ for a in result)
  265. response = Response()
  266. response.status_code = 200
  267. response.mimetype = "application/json"
  268. response.data = json.dumps(result)
  269. return response(environ, start_response)
  270. class HistoryHandler:
  271. def __init__(self, stores):
  272. self.scoring_store = stores["scoring"]
  273. def __call__(self, environ, start_response):
  274. return self.wsgi_app(environ, start_response)
  275. def wsgi_app(self, environ, start_response):
  276. request = Request(environ)
  277. request.encoding_errors = "strict"
  278. if request.accept_mimetypes.quality("application/json") <= 0:
  279. raise NotAcceptable()
  280. result = list(self.scoring_store.get_global_history())
  281. response = Response()
  282. response.status_code = 200
  283. response.mimetype = "application/json"
  284. response.data = json.dumps(result)
  285. return response(environ, start_response)
  286. class ScoreHandler:
  287. def __init__(self, stores):
  288. self.scoring_store = stores["scoring"]
  289. def __call__(self, environ, start_response):
  290. return self.wsgi_app(environ, start_response)
  291. def wsgi_app(self, environ, start_response):
  292. request = Request(environ)
  293. request.encoding_errors = "strict"
  294. if request.accept_mimetypes.quality("application/json") <= 0:
  295. raise NotAcceptable()
  296. result = dict()
  297. for u_id, tasks in self.scoring_store._scores.items():
  298. for t_id, score in tasks.items():
  299. if score.get_score() > 0.0:
  300. result.setdefault(u_id, dict())[t_id] = score.get_score()
  301. response = Response()
  302. response.status_code = 200
  303. response.headers['Timestamp'] = "%0.6f" % time.time()
  304. response.mimetype = "application/json"
  305. response.data = json.dumps(result)
  306. return response(environ, start_response)
  307. class ImageHandler:
  308. EXT_TO_MIME = {
  309. 'png': 'image/png',
  310. 'jpg': 'image/jpeg',
  311. 'gif': 'image/gif',
  312. 'bmp': 'image/bmp'
  313. }
  314. MIME_TO_EXT = dict((v, k) for k, v in EXT_TO_MIME.items())
  315. def __init__(self, location, fallback):
  316. self.location = location
  317. self.fallback = fallback
  318. self.router = Map([
  319. Rule("/<name>", methods=["GET"], endpoint="get"),
  320. ], encoding_errors="strict")
  321. def __call__(self, environ, start_response):
  322. return self.wsgi_app(environ, start_response)
  323. @responder
  324. def wsgi_app(self, environ, start_response):
  325. route = self.router.bind_to_environ(environ)
  326. try:
  327. endpoint, args = route.match()
  328. except HTTPException as exc:
  329. return exc
  330. location = self.location % args
  331. request = Request(environ)
  332. request.encoding_errors = "strict"
  333. response = Response()
  334. available = list()
  335. for extension, mimetype in self.EXT_TO_MIME.items():
  336. if os.path.isfile(location + '.' + extension):
  337. available.append(mimetype)
  338. mimetype = request.accept_mimetypes.best_match(available)
  339. if mimetype is not None:
  340. path = "%s.%s" % (location, self.MIME_TO_EXT[mimetype])
  341. else:
  342. path = self.fallback
  343. mimetype = 'image/png' # FIXME Hardcoded type.
  344. response.status_code = 200
  345. response.mimetype = mimetype
  346. response.last_modified = \
  347. datetime.utcfromtimestamp(os.path.getmtime(path))\
  348. .replace(microsecond=0)
  349. # TODO check for If-Modified-Since and If-None-Match
  350. response.response = wrap_file(environ, open(path, 'rb'))
  351. response.direct_passthrough = True
  352. return response
  353. class RootHandler:
  354. def __init__(self, location):
  355. self.path = os.path.join(location, "Ranking.html")
  356. def __call__(self, environ, start_response):
  357. return self.wsgi_app(environ, start_response)
  358. @responder
  359. def wsgi_app(self, environ, start_response):
  360. request = Request(environ)
  361. request.encoding_errors = "strict"
  362. response = Response()
  363. response.status_code = 200
  364. response.mimetype = "text/html"
  365. response.last_modified = \
  366. datetime.utcfromtimestamp(os.path.getmtime(self.path))\
  367. .replace(microsecond=0)
  368. # TODO check for If-Modified-Since and If-None-Match
  369. response.response = wrap_file(environ, open(self.path, 'rb'))
  370. response.direct_passthrough = True
  371. return response
  372. class RoutingHandler:
  373. def __init__(self, root_handler, event_handler, logo_handler,
  374. score_handler, history_handler):
  375. self.router = Map([
  376. Rule("/", methods=["GET"], endpoint="root"),
  377. Rule("/history", methods=["GET"], endpoint="history"),
  378. Rule("/scores", methods=["GET"], endpoint="scores"),
  379. Rule("/events", methods=["GET"], endpoint="events"),
  380. Rule("/logo", methods=["GET"], endpoint="logo"),
  381. ], encoding_errors="strict")
  382. self.event_handler = event_handler
  383. self.logo_handler = logo_handler
  384. self.score_handler = score_handler
  385. self.history_handler = history_handler
  386. self.root_handler = root_handler
  387. def __call__(self, environ, start_response):
  388. return self.wsgi_app(environ, start_response)
  389. def wsgi_app(self, environ, start_response):
  390. route = self.router.bind_to_environ(environ)
  391. try:
  392. endpoint, args = route.match()
  393. except HTTPException as exc:
  394. return exc(environ, start_response)
  395. if endpoint == "events":
  396. return self.event_handler(environ, start_response)
  397. elif endpoint == "logo":
  398. return self.logo_handler(environ, start_response)
  399. elif endpoint == "root":
  400. return self.root_handler(environ, start_response)
  401. elif endpoint == "scores":
  402. return self.score_handler(environ, start_response)
  403. elif endpoint == "history":
  404. return self.history_handler(environ, start_response)
  405. def main():
  406. """Entry point for RWS.
  407. return (int): exit code (0 on success, 1 on error)
  408. """
  409. parser = argparse.ArgumentParser(
  410. description="Ranking for CMS.")
  411. parser.add_argument("--config", type=argparse.FileType("rt"),
  412. help="override config file")
  413. parser.add_argument("-d", "--drop", action="store_true",
  414. help="drop the data already stored")
  415. parser.add_argument("-y", "--yes", action="store_true",
  416. help="do not require confirmation on dropping data")
  417. args = parser.parse_args()
  418. config = Config()
  419. config.load(args.config)
  420. if args.drop:
  421. if args.yes:
  422. ans = 'y'
  423. else:
  424. ans = input("Are you sure you want to delete directory %s? [y/N] " %
  425. config.lib_dir).strip().lower()
  426. if ans in ['y', 'yes']:
  427. print("Removing directory %s." % config.lib_dir)
  428. shutil.rmtree(config.lib_dir)
  429. else:
  430. print("Not removing directory %s." % config.lib_dir)
  431. return 0
  432. stores = dict()
  433. stores["subchange"] = Store(
  434. Subchange, os.path.join(config.lib_dir, 'subchanges'), stores)
  435. stores["submission"] = Store(
  436. Submission, os.path.join(config.lib_dir, 'submissions'), stores,
  437. [stores["subchange"]])
  438. stores["user"] = Store(
  439. User, os.path.join(config.lib_dir, 'users'), stores,
  440. [stores["submission"]])
  441. stores["team"] = Store(
  442. Team, os.path.join(config.lib_dir, 'teams'), stores,
  443. [stores["user"]])
  444. stores["task"] = Store(
  445. Task, os.path.join(config.lib_dir, 'tasks'), stores,
  446. [stores["submission"]])
  447. stores["contest"] = Store(
  448. Contest, os.path.join(config.lib_dir, 'contests'), stores,
  449. [stores["task"]])
  450. stores["contest"].load_from_disk()
  451. stores["task"].load_from_disk()
  452. stores["team"].load_from_disk()
  453. stores["user"].load_from_disk()
  454. stores["submission"].load_from_disk()
  455. stores["subchange"].load_from_disk()
  456. stores["scoring"] = ScoringStore(stores)
  457. stores["scoring"].init_store()
  458. toplevel_handler = RoutingHandler(
  459. RootHandler(config.web_dir),
  460. DataWatcher(stores, config.buffer_size),
  461. ImageHandler(
  462. os.path.join(config.lib_dir, '%(name)s'),
  463. os.path.join(config.web_dir, 'img', 'logo.png')),
  464. ScoreHandler(stores),
  465. HistoryHandler(stores))
  466. wsgi_app = SharedDataMiddleware(DispatcherMiddleware(
  467. toplevel_handler, {
  468. '/contests': StoreHandler(
  469. stores["contest"],
  470. config.username, config.password, config.realm_name),
  471. '/tasks': StoreHandler(
  472. stores["task"],
  473. config.username, config.password, config.realm_name),
  474. '/teams': StoreHandler(
  475. stores["team"],
  476. config.username, config.password, config.realm_name),
  477. '/users': StoreHandler(
  478. stores["user"],
  479. config.username, config.password, config.realm_name),
  480. '/submissions': StoreHandler(
  481. stores["submission"],
  482. config.username, config.password, config.realm_name),
  483. '/subchanges': StoreHandler(
  484. stores["subchange"],
  485. config.username, config.password, config.realm_name),
  486. '/faces': ImageHandler(
  487. os.path.join(config.lib_dir, 'faces', '%(name)s'),
  488. os.path.join(config.web_dir, 'img', 'face.png')),
  489. '/flags': ImageHandler(
  490. os.path.join(config.lib_dir, 'flags', '%(name)s'),
  491. os.path.join(config.web_dir, 'img', 'flag.png')),
  492. '/sublist': SubListHandler(stores),
  493. }), {'/': config.web_dir})
  494. servers = list()
  495. if config.http_port is not None:
  496. http_server = WSGIServer(
  497. (config.bind_address, config.http_port), wsgi_app)
  498. servers.append(http_server)
  499. if config.https_port is not None:
  500. https_server = WSGIServer(
  501. (config.bind_address, config.https_port), wsgi_app,
  502. certfile=config.https_certfile, keyfile=config.https_keyfile)
  503. servers.append(https_server)
  504. try:
  505. gevent.joinall(list(gevent.spawn(s.serve_forever) for s in servers))
  506. except KeyboardInterrupt:
  507. pass
  508. finally:
  509. gevent.joinall(list(gevent.spawn(s.stop) for s in servers))
  510. return 0