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 @@