diff --git a/cms/io/web_service.py b/cms/io/web_service.py index 2d7390e657..da56cf4976 100644 --- a/cms/io/web_service.py +++ b/cms/io/web_service.py @@ -19,7 +19,9 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import hashlib import logging +import importlib.resources import collections try: @@ -36,6 +38,7 @@ from cms.db.filecacher import FileCacher from cms.server.file_middleware import FileServerMiddleware +from cms.server.util import Url from .service import Service from .web_rpc import RPCMiddleware @@ -45,6 +48,58 @@ SECONDS_IN_A_YEAR = 365 * 24 * 60 * 60 +class StaticFileHasher: + """ + Constructs URLs to static files. The result of make() is similar to the + url() function that's used in the templates, in that it constructs a + relative URL, but it also adds a "?h=12345678" query parameter which forces + browsers to reload the resource when it has changed. + """ + def __init__(self, files: list[tuple[str, str]]): + """ + Initialize. + + files: list of static file locations, each in the format that would be + passed to SharedDataMiddleware. + """ + # Cache of the hashes of files, to prevent re-hashing them on every request. + self.cache: dict[tuple[str, ...], str] = {} + # We reverse the order, because in WSGI later-added middlewares + # override earlier ones, but here we iterate the locations and use the + # first found match. + self.static_locations = files[::-1] + + def make(self, base_url: Url): + """ + Create a new url helper function (called once per request). + + The returned function takes arguments in the same format as `Url`, and + returns a string in the same format as `Url` except with a hash + appended as a query string. + """ + def inner_func(*paths: str): + # WebService always serves the static files under /static. + assert paths[0] == "static" + + url_path_part = base_url(*paths) + + if paths in self.cache: + return url_path_part + self.cache[paths] + + for module_name, dir in self.static_locations: + resource = importlib.resources.files(module_name).joinpath(dir, *paths[1:]) + if resource.is_file(): + with resource.open('rb') as file: + hash = hashlib.file_digest(file, hashlib.sha256).hexdigest() + result = "?h=" + hash[:24] + break + else: + logger.warning(f"Did not find path passed to static_url(): {paths}") + result = "" + + self.cache[paths] = result + return url_path_part + result + return inner_func class WebService(Service): """RPC service with Web server capabilities. @@ -78,6 +133,8 @@ def __init__( cache=True, cache_timeout=SECONDS_IN_A_YEAR, fallback_mimetype="application/octet-stream") + self.static_file_hasher = StaticFileHasher(static_files) + self.file_cacher = FileCacher(self) self.wsgi_app = FileServerMiddleware(self.file_cacher, self.wsgi_app) diff --git a/cms/server/admin/handlers/base.py b/cms/server/admin/handlers/base.py index 9418df8706..91780809cf 100644 --- a/cms/server/admin/handlers/base.py +++ b/cms/server/admin/handlers/base.py @@ -326,6 +326,7 @@ def render_params(self) -> dict: params["timestamp"] = make_datetime() params["contest"] = self.contest params["url"] = self.url + params["static_url"] = self.static_url_helper params["xsrf_form_html"] = self.xsrf_form_html() # FIXME These objects provide too broad an access: their usage # should be extracted into with narrower-scoped parameters. diff --git a/cms/server/admin/templates/base.html b/cms/server/admin/templates/base.html index ff6b099509..b0965b1ffc 100644 --- a/cms/server/admin/templates/base.html +++ b/cms/server/admin/templates/base.html @@ -2,18 +2,18 @@ - - - - - - - - - - - - + + + + + + + + + + + + {% if contest is none %} Admin diff --git a/cms/server/admin/templates/login.html b/cms/server/admin/templates/login.html index 3e6a45ae54..5b8079f57e 100644 --- a/cms/server/admin/templates/login.html +++ b/cms/server/admin/templates/login.html @@ -3,9 +3,9 @@ - - - + + + Admin diff --git a/cms/server/admin/templates/overview.html b/cms/server/admin/templates/overview.html index 4e1a0e3d23..5019f1d79e 100644 --- a/cms/server/admin/templates/overview.html +++ b/cms/server/admin/templates/overview.html @@ -258,7 +258,7 @@

Submissions status

- loading... + loading...
@@ -277,7 +277,7 @@

Queue status

- loading... + loading...
@@ -296,7 +296,7 @@

Workers status

- loading... + loading...
@@ -316,7 +316,7 @@

Logs

- loading... + loading...
diff --git a/cms/server/admin/templates/resources.html b/cms/server/admin/templates/resources.html index 166469ead5..f3e327a315 100644 --- a/cms/server/admin/templates/resources.html +++ b/cms/server/admin/templates/resources.html @@ -211,7 +211,7 @@ kill_service: function(s, link) { - link.parentNode.innerHTML = 'loading...'; + link.parentNode.innerHTML = 'loading...'; cmsrpc_request("ResourceService", this.shard, "kill_service", {"service": s}); @@ -302,7 +302,7 @@

Machine {{ i }} ({{ resource_ - loading... + loading... diff --git a/cms/server/contest/handlers/base.py b/cms/server/contest/handlers/base.py index ebc29f8e6a..b6c04fd7dc 100644 --- a/cms/server/contest/handlers/base.py +++ b/cms/server/contest/handlers/base.py @@ -136,6 +136,7 @@ def render_params(self) -> dict: ret["now"] = self.timestamp ret["utc"] = utc_tzinfo ret["url"] = self.url + ret["static_url"] = self.static_url_helper ret["available_translations"] = self.available_translations diff --git a/cms/server/contest/templates/base.html b/cms/server/contest/templates/base.html index f282230bcb..0d5697ac3d 100644 --- a/cms/server/contest/templates/base.html +++ b/cms/server/contest/templates/base.html @@ -6,15 +6,15 @@ {% block title %}{% endblock title %} - - - + + + - + {# For compatibility with Bootstrap 2.x #} - - - + + + {% block js %}{% endblock js %} diff --git a/cms/server/contest/templates/macro/submission.html b/cms/server/contest/templates/macro/submission.html index e7832ebc4e..00affc7285 100644 --- a/cms/server/contest/templates/macro/submission.html +++ b/cms/server/contest/templates/macro/submission.html @@ -1,4 +1,4 @@ -{% macro rows(url, contest_url, translation, xsrf_form_html, +{% macro rows(url, static_url, contest_url, translation, xsrf_form_html, actual_phase, task, submissions, can_use_tokens, can_play_token, can_play_token_now, submissions_download_allowed, official) -%} @@ -6,6 +6,7 @@ Render a submission table with all (un)official submissions passed. url (Url): the URL instance referring to the root of CWS. +static_url: static url helper constructed from url contest_url (Url): the URL instance referring to the main contest page. translation (Translation): locale to use to show messages. xsrf_form_html (str): input element for the XSRF protection. @@ -90,6 +91,7 @@ {# loop.revindex is broken: https://github.com/pallets/jinja/issues/794 #} {{ row( url, + static_url, contest_url, translation, xsrf_form_html, @@ -108,7 +110,7 @@ {%- endmacro %} -{% macro row(url, contest_url, translation, xsrf_form_html, +{% macro row(url, static_url, contest_url, translation, xsrf_form_html, actual_phase, s, opaque_id, show_date, can_use_tokens, can_play_token, can_play_token_now, submissions_download_allowed) -%} @@ -116,6 +118,7 @@ Render a row in a submission table. url (Url): the URL instance referring to the root of CWS. +static_url: static url helper constructed from url contest_url (Url): the URL instance referring to the main contest page. translation (Translation): locale to use to show messages. xsrf_form_html (str): input element for the XSRF protection. @@ -146,16 +149,16 @@ {% if status == SubmissionResult.COMPILING %} {% trans %}Compiling...{% endtrans %} - + {% elif status == SubmissionResult.COMPILATION_FAILED %} {% trans %}Compilation failed{% endtrans %} {% trans %}details{% endtrans %} {% elif status == SubmissionResult.EVALUATING %} {% trans %}Evaluating...{% endtrans %} - + {% elif status == SubmissionResult.SCORING %} {% trans %}Scoring...{% endtrans %} - + {% elif status == SubmissionResult.SCORED %} {% trans %}Evaluated{% endtrans %} {% trans %}details{% endtrans %} diff --git a/cms/server/contest/templates/task_description.html b/cms/server/contest/templates/task_description.html index 2948274a14..454a1f55c1 100644 --- a/cms/server/contest/templates/task_description.html +++ b/cms/server/contest/templates/task_description.html @@ -186,9 +186,9 @@

{% trans %}Attachments{% endtrans %}

  • {% if type_icon is not none %} - {{ mime_type }} + {{ mime_type }} {% else %} - {% trans %}unknown{% endtrans %} + {% trans %}unknown{% endtrans %} {% endif %} {{ filename }} diff --git a/cms/server/contest/templates/task_submissions.html b/cms/server/contest/templates/task_submissions.html index ea5a08f2b8..eb13c6315c 100644 --- a/cms/server/contest/templates/task_submissions.html +++ b/cms/server/contest/templates/task_submissions.html @@ -39,7 +39,7 @@ var submission_id = $(this).parent().parent().attr("data-submission"); var modal = $("#submission_detail"); var modal_body = modal.children(".modal-body"); - modal_body.html('
    {% trans %}loading...{% endtrans %}
    '); + modal_body.html('
    {% trans %}loading...{% endtrans %}
    '); modal_body.load(utils.contest_url("tasks", "{{ task.name }}", "submissions", submission_id, "details"), function(response, status, xhr) { if(status != "success") { $(this).html("{% trans %}Error loading details, please refresh the page.{% endtrans %}"); @@ -108,7 +108,7 @@ task_score_span.text(task_score_message); if (task_score_is_partial) { task_score_span.append( - $("")); + $("")); } task_score_elem.removeClass("undefined"); task_score_elem.removeClass("score_0"); @@ -124,7 +124,7 @@ var terminal_status = is_status_terminal(data["status"]); if (!terminal_status) { row.children("td.status").append( - $("")); + $("")); } else { row.children("td.status").append( $("
    {% trans %}details{% endtrans %}")); @@ -205,7 +205,7 @@

    {% trans name=task.title, short_name=task.name %}{{ name }} ({{ short_name } {{ score_type.format_score(public_score, score_type.max_public_score, none, task.score_precision, translation=translation) }} {% if is_score_partial %} - + {% endif %} @@ -227,7 +227,7 @@

    {% trans name=task.title, short_name=task.name %}{{ name }} ({{ short_name } {% if can_use_tokens %} {{ score_type.format_score(tokened_score, score_type.max_score, none, task.score_precision, translation=translation) }} {% if is_score_partial %} - + {% endif %} {% else %} {% trans %}N/A{% endtrans %} @@ -366,6 +366,7 @@

    {% trans %}Previous submissions{% endtrans %}{% trans %}Unofficial submissions{% endtrans %}

    {{ macro_submission.rows( url, + static_url, contest_url, translation, xsrf_form_html, @@ -382,6 +383,7 @@

    {% trans %}Official submissions{% endtrans %}

    {{ macro_submission.rows( url, + static_url, contest_url, translation, xsrf_form_html, diff --git a/cms/server/contest/templates/test_interface.html b/cms/server/contest/templates/test_interface.html index e4c7ab621f..104e3a74ad 100644 --- a/cms/server/contest/templates/test_interface.html +++ b/cms/server/contest/templates/test_interface.html @@ -24,7 +24,7 @@ var user_test_id = $this.parent().parent().attr("data-user-test"); var modal = $("#user_test_detail"); var modal_body = modal.children(".modal-body"); - modal_body.html('
    {% trans %}loading...{% endtrans %}
    '); + modal_body.html('
    {% trans %}loading...{% endtrans %}
    '); modal_body.load(utils.contest_url("tasks", task_id, "tests", user_test_id, "details"), function(response, status, xhr) { if(status != "success") { $(this).html("{% trans %}Error loading details, please refresh the page.{% endtrans %}"); diff --git a/cms/server/util.py b/cms/server/util.py index 3f18da3951..3251a1fc24 100644 --- a/cms/server/util.py +++ b/cms/server/util.py @@ -45,6 +45,8 @@ from cms.server.file_middleware import FileServerMiddleware from cmscommon.datetime import make_datetime +if typing.TYPE_CHECKING: + from cms.io.web_service import WebService logger = logging.getLogger(__name__) @@ -183,6 +185,7 @@ def __init__(self, *args, **kwargs): self.r_params = None self.contest = None self.url: Url = None + self.static_url_helper = None def prepare(self): """This method is executed at the beginning of each request. @@ -190,6 +193,7 @@ def prepare(self): """ super().prepare() self.url = Url(get_url_root(self.request.path)) + self.static_url_helper = self.service.static_file_hasher.make(self.url) self.set_header("Cache-Control", "no-cache, must-revalidate") def finish(self, *args, **kwargs): @@ -216,5 +220,5 @@ def finish(self, *args, **kwargs): logger.debug("Connection closed before our reply.") @property - def service(self): + def service(self) -> "WebService": return self.application.service