diff --git a/mo/ext/__init__.py b/mo/ext/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..76dd78e636ddca047cdfcf7569b7edee6ffc8f0f
--- /dev/null
+++ b/mo/ext/__init__.py
@@ -0,0 +1,3 @@
+# mo.ext does nothing by itself
+
+pass
diff --git a/mo/ext/assets.py b/mo/ext/assets.py
new file mode 100644
index 0000000000000000000000000000000000000000..97a9bcdd5e9c1b189125b8035a9c46efd75b704f
--- /dev/null
+++ b/mo/ext/assets.py
@@ -0,0 +1,62 @@
+# Flask extension for versioned assets
+
+from flask import send_from_directory
+import hashlib
+import os
+from typing import Sequence, Dict
+
+
+class Assets:
+
+    asset_dir: str
+    asset_dict: Dict[str, str]
+    url_prefix: str
+
+    def __init__(self, app, url_prefix: str, asset_dir: str):
+        self.app = app
+        if app is not None:
+            self.init_app(app, url_prefix, asset_dir)
+
+    def init_app(self, app, url_prefix: str, asset_dir: str):
+        self.asset_folder = asset_dir
+        self.asset_dict = {}
+        self.url_prefix = url_prefix
+        self.app = app
+        app.jinja_env.globals.update(asset_url=lambda name: self.asset_url(name))
+        app.assets = self
+
+        # This is usually needed only for development, production requests are handled by upstream proxy
+        app.add_url_rule(
+            url_prefix + "/<version>/<path:name>",
+            endpoint="assets",
+            view_func=lambda version, name: self.send_asset(name),
+        )
+
+    def add_asset(self, name: str):
+        if name in self.asset_dict:
+            return
+
+        file_name = os.path.join(self.asset_folder, name)
+        digest = hashlib.sha1()
+
+        with open(file_name, 'rb') as file:
+            while True:
+                block = file.read(4096)
+                if not block:
+                    break
+                digest.update(block)
+
+        version = digest.hexdigest()[:8]
+        self.app.logger.debug(f'Assets: Loaded {name}: version {version}')
+        self.asset_dict[name] = version
+
+    def add_assets(self, names: Sequence[str]):
+        for name in names:
+            self.add_asset(name)
+
+    def asset_url(self, name: str) -> str:
+        assert name in self.asset_dict
+        return os.path.join(self.url_prefix, self.asset_dict[name], name)
+
+    def send_asset(self, name: str):
+        return send_from_directory(self.asset_folder, name)
diff --git a/setup.py b/setup.py
index 073ae8a17a5c03ae695caa6a9b5ee3d65e62a1b8..5b7705e6c8a4f95cd17c41da0ca88e673970ecb9 100644
--- a/setup.py
+++ b/setup.py
@@ -6,7 +6,7 @@ setuptools.setup(
     name='osmo',
     version='0.1',
     description='Odevzdávací systém Matematické olympiády',
-    packages=['mo', 'mo/jobs', 'mo/web'],
+    packages=['mo', 'mo/ext', 'mo/jobs', 'mo/web'],
     scripts=[
         'bin/add-role',
         'bin/create-contests',