Skip to content

Commit eef3c9d

Browse files
authored
Improve Caching Schema (#50)
- Use Django async caching API - Remove async_lru_cache. - Fix #46 - Use aiofile for async file operations. - Protect against malicious file paths - Use cache versioning to invalidate old web module files stored in cache
1 parent 940fbe2 commit eef3c9d

File tree

6 files changed

+40
-65
lines changed

6 files changed

+40
-65
lines changed

.github/workflows/test.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
runs-on: ubuntu-latest
1616
strategy:
1717
matrix:
18-
python-version: [3.7, 3.8, 3.9]
18+
python-version: ["3.8", "3.9", "3.10"]
1919
steps:
2020
- uses: actions/checkout@v2
2121
- uses: nanasess/setup-chromedriver@master

README.md

+2-8
Original file line numberDiff line numberDiff line change
@@ -106,20 +106,14 @@ You may configure additional options as well:
106106
# the base URL for all IDOM-releated resources
107107
IDOM_BASE_URL: str = "_idom/"
108108

109-
# Set cache size limit for loading JS files for IDOM.
110-
# Only applies when not using Django's caching framework (see below).
111-
IDOM_WEB_MODULE_LRU_CACHE_SIZE: int | None = None
112-
113109
# Maximum seconds between two reconnection attempts that would cause the client give up.
114110
# 0 will disable reconnection.
115111
IDOM_WS_MAX_RECONNECT_DELAY: int = 604800
116112

117113
# Configure a cache for loading JS files
118114
CACHES = {
119-
# Configure a cache for loading JS files for IDOM
120-
"idom_web_modules": {"BACKEND": ...},
121-
# If the above cache is not configured, then we'll use the "default" instead
122-
"default": {"BACKEND": ...},
115+
# If "idom" cache is not configured, then we'll use the "default" instead
116+
"idom": {"BACKEND": ...},
123117
}
124118
```
125119

requirements/pkg-deps.txt

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
channels <4.0.0
22
idom >=0.34.0, <0.35.0
3+
aiofile >=3.0, <4.0

setup.py

+3-4
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def list2cmdline(cmd_list):
3838

3939
package = {
4040
"name": name,
41-
"python_requires": ">=3.7",
41+
"python_requires": ">=3.8",
4242
"packages": find_packages(str(src_dir)),
4343
"package_dir": {"": "src"},
4444
"description": "Control the web with Python",
@@ -52,15 +52,14 @@ def list2cmdline(cmd_list):
5252
"zip_safe": False,
5353
"classifiers": [
5454
"Framework :: Django",
55-
"Framework :: Django :: 3.1",
56-
"Framework :: Django :: 3.2",
55+
"Framework :: Django :: 4.0",
5756
"Operating System :: OS Independent",
5857
"Intended Audience :: Developers",
5958
"Intended Audience :: Science/Research",
6059
"Topic :: Multimedia :: Graphics",
61-
"Programming Language :: Python :: 3.7",
6260
"Programming Language :: Python :: 3.8",
6361
"Programming Language :: Python :: 3.9",
62+
"Programming Language :: Python :: 3.10",
6463
"Environment :: Web Environment",
6564
],
6665
}

src/django_idom/config.py

+4-14
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from typing import Dict
22

33
from django.conf import settings
4-
from django.core.cache import DEFAULT_CACHE_ALIAS
4+
from django.core.cache import DEFAULT_CACHE_ALIAS, caches
55
from idom.core.proto import ComponentConstructor
66

77

@@ -12,17 +12,7 @@
1212
IDOM_WEB_MODULES_URL = IDOM_BASE_URL + "web_module/"
1313
IDOM_WS_MAX_RECONNECT_DELAY = getattr(settings, "IDOM_WS_MAX_RECONNECT_DELAY", 604800)
1414

15-
_CACHES = getattr(settings, "CACHES", {})
16-
if _CACHES:
17-
if "idom_web_modules" in getattr(settings, "CACHES", {}):
18-
IDOM_WEB_MODULE_CACHE = "idom_web_modules"
19-
else:
20-
IDOM_WEB_MODULE_CACHE = DEFAULT_CACHE_ALIAS
15+
if "idom" in getattr(settings, "CACHES", {}):
16+
IDOM_CACHE = caches["idom"]
2117
else:
22-
IDOM_WEB_MODULE_CACHE = None
23-
24-
25-
# the LRU cache size for the route serving IDOM_WEB_MODULES_DIR files
26-
IDOM_WEB_MODULE_LRU_CACHE_SIZE = getattr(
27-
settings, "IDOM_WEB_MODULE_LRU_CACHE_SIZE", None
28-
)
18+
IDOM_CACHE = caches[DEFAULT_CACHE_ALIAS]

src/django_idom/views.py

+29-38
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,34 @@
1-
import asyncio
2-
import functools
31
import os
42

5-
from django.core.cache import caches
3+
from aiofile import async_open
4+
from django.core.exceptions import SuspiciousOperation
65
from django.http import HttpRequest, HttpResponse
76
from idom.config import IDOM_WED_MODULES_DIR
87

9-
from .config import IDOM_WEB_MODULE_CACHE, IDOM_WEB_MODULE_LRU_CACHE_SIZE
10-
11-
12-
if IDOM_WEB_MODULE_CACHE is None:
13-
14-
def async_lru_cache(*lru_cache_args, **lru_cache_kwargs):
15-
def async_lru_cache_decorator(async_function):
16-
@functools.lru_cache(*lru_cache_args, **lru_cache_kwargs)
17-
def cached_async_function(*args, **kwargs):
18-
coroutine = async_function(*args, **kwargs)
19-
return asyncio.ensure_future(coroutine)
20-
21-
return cached_async_function
22-
23-
return async_lru_cache_decorator
24-
25-
@async_lru_cache(IDOM_WEB_MODULE_LRU_CACHE_SIZE)
26-
async def web_modules_file(request: HttpRequest, file: str) -> HttpResponse:
27-
file_path = IDOM_WED_MODULES_DIR.current.joinpath(*file.split("/"))
28-
return HttpResponse(file_path.read_text(), content_type="text/javascript")
29-
30-
else:
31-
_web_module_cache = caches[IDOM_WEB_MODULE_CACHE]
32-
33-
async def web_modules_file(request: HttpRequest, file: str) -> HttpResponse:
34-
file = IDOM_WED_MODULES_DIR.current.joinpath(*file.split("/")).absolute()
35-
last_modified_time = os.stat(file).st_mtime
36-
cache_key = f"{file}:{last_modified_time}"
37-
38-
response = _web_module_cache.get(cache_key)
39-
if response is None:
40-
response = HttpResponse(file.read_text(), content_type="text/javascript")
41-
_web_module_cache.set(cache_key, response, timeout=None)
42-
43-
return response
8+
from .config import IDOM_CACHE
9+
10+
11+
async def web_modules_file(request: HttpRequest, file: str) -> HttpResponse:
12+
"""Gets JavaScript required for IDOM modules at runtime. These modules are
13+
returned from cache if available."""
14+
web_modules_dir = IDOM_WED_MODULES_DIR.current
15+
path = web_modules_dir.joinpath(*file.split("/")).absolute()
16+
17+
# Prevent attempts to walk outside of the web modules dir
18+
if str(web_modules_dir) != os.path.commonpath((path, web_modules_dir)):
19+
raise SuspiciousOperation(
20+
"Attempt to access a directory outside of IDOM_WED_MODULES_DIR."
21+
)
22+
23+
# Fetch the file from cache, if available
24+
last_modified_time = os.stat(path).st_mtime
25+
cache_key = f"django_idom:web_module:{str(path).lstrip(str(web_modules_dir))}"
26+
response = await IDOM_CACHE.aget(cache_key, version=last_modified_time)
27+
if response is None:
28+
async with async_open(path, "r") as fp:
29+
response = HttpResponse(await fp.read(), content_type="text/javascript")
30+
await IDOM_CACHE.adelete(cache_key)
31+
await IDOM_CACHE.aset(
32+
cache_key, response, timeout=None, version=last_modified_time
33+
)
34+
return response

0 commit comments

Comments
 (0)