feat: adopt Python 3.15 and harden caching and share links
Build and Push Docker Container / build-and-push (push) Successful in 3m58s
Build and Push Docker Container / build-and-push (push) Successful in 3m58s
- Switch local Python and the Docker base image to Python 3.15. - Refresh locked dependencies with Python-version-specific resolution. - Replace Pickle caching with native Redis strings, lists, and typed JSON. - Validate cache TTLs and apply expiry atomically on Redis writes. - Treat legacy binary cache values as misses and clear keys with SCAN. - Ignore trailing prose after decoded spaces in share-link paths. - Reject empty or slash-containing share IDs before database lookup. - Add regression tests for plain and encoded spaces in share links. - Update helper submodule for config lookup and tabular storage fixes. - Update quart_common with JSON error response detection. - Verify all 150 tests pass on CPython 3.15.0.
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
3.14
|
||||
3.15
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim
|
||||
FROM ghcr.io/astral-sh/uv:python3.15-trixie-slim
|
||||
|
||||
# Install app
|
||||
COPY . /app
|
||||
|
||||
+1
-1
Submodule my_helpers updated: 1aa18f19b2...a4f068d34a
+78
-66
@@ -1,12 +1,35 @@
|
||||
import redis.asyncio as aioredis
|
||||
import asyncio
|
||||
import pickle
|
||||
import json
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from redis.exceptions import ResponseError
|
||||
|
||||
# One server-side operation: a successful write always has an expiry.
|
||||
_SET_JSON = """
|
||||
local kind = redis.call('TYPE', KEYS[1]).ok
|
||||
if kind == 'string' or kind == 'list' then
|
||||
redis.call('DEL', KEYS[1])
|
||||
end
|
||||
redis.call('JSON.SET', KEYS[1], '$', ARGV[1])
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[2])
|
||||
return 1
|
||||
"""
|
||||
|
||||
_SET_LIST = """
|
||||
redis.call('DEL', KEYS[1])
|
||||
for i = 2, #ARGV do
|
||||
redis.call('RPUSH', KEYS[1], ARGV[i])
|
||||
end
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||||
return 1
|
||||
"""
|
||||
|
||||
class AsyncCache:
|
||||
"""Native strings/string lists and typed JSON, with expiry on every write."""
|
||||
|
||||
def __init__(self, backend="memory", prefix="cache:", default_ttl=300, **kwargs):
|
||||
self.prefix = prefix
|
||||
self.ttl = default_ttl
|
||||
|
||||
if backend == "redis":
|
||||
self.backend_type = "redis"
|
||||
self.redis = aioredis.Redis(
|
||||
@@ -22,84 +45,73 @@ class AsyncCache:
|
||||
self._store = {}
|
||||
self._expiry = {}
|
||||
|
||||
# --------- asynchrone Pickle-Helfer -------------
|
||||
async def _pickle_dumps(self, value):
|
||||
"""Pickle.dumps in Threadpool ausführen."""
|
||||
return await asyncio.to_thread(pickle.dumps, value, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
def _key(self, key):
|
||||
return f"{self.prefix}{key}"
|
||||
|
||||
async def _pickle_loads(self, data):
|
||||
"""Pickle.loads in Threadpool ausführen."""
|
||||
return await asyncio.to_thread(pickle.loads, data)
|
||||
|
||||
# --------- Flask-Caching-ähnliche Serialisierung -------------
|
||||
async def dump_object(self, value) -> bytes:
|
||||
"""
|
||||
* int -> ASCII-Bytes ohne Prefix
|
||||
* alles andere -> b"!" + Pickle-Bytes
|
||||
"""
|
||||
if isinstance(value, int):
|
||||
return str(value).encode("ascii")
|
||||
pickled = await self._pickle_dumps(value)
|
||||
return b"!" + pickled
|
||||
|
||||
async def load_object(self, value: bytes):
|
||||
"""
|
||||
* beginnt mit b"!" -> Pickle.loads
|
||||
* ansonsten -> int oder UTF-8-String
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if value.startswith(b"!"):
|
||||
return await self._pickle_loads(value[1:])
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return value.decode("utf-8", errors="ignore")
|
||||
|
||||
# ----------------- Öffentliche API -----------------
|
||||
async def set(self, key, value, ttl=None):
|
||||
ttl = ttl or self.ttl
|
||||
full_key = f"{self.prefix}{key}"
|
||||
dumped = await self.dump_object(value)
|
||||
|
||||
ttl = self.ttl if ttl is None else ttl
|
||||
if isinstance(ttl, bool) or not isinstance(ttl, int) or ttl <= 0:
|
||||
raise ValueError("Cache TTL must be a positive integer")
|
||||
dumped = json.dumps(value, ensure_ascii=False, allow_nan=False)
|
||||
full_key = self._key(key)
|
||||
if self.backend_type == "redis":
|
||||
return await self.redis.set(full_key, dumped, ex=ttl)
|
||||
else:
|
||||
self._store[full_key] = dumped
|
||||
self._expiry[full_key] = asyncio.get_event_loop().time() + ttl
|
||||
return True
|
||||
if isinstance(value, str):
|
||||
# SET replaces either a string or a JSON key and sets TTL atomically.
|
||||
return bool(await self.redis.set(full_key, value, ex=ttl))
|
||||
if isinstance(value, list) and value and all(isinstance(item, str) for item in value):
|
||||
return bool(await self.redis.eval(_SET_LIST, 1, full_key, ttl, *value))
|
||||
return bool(await self.redis.eval(_SET_JSON, 1, full_key, dumped, ttl))
|
||||
self._store[full_key] = dumped
|
||||
self._expiry[full_key] = asyncio.get_running_loop().time() + ttl
|
||||
return True
|
||||
|
||||
async def get(self, key):
|
||||
full_key = f"{self.prefix}{key}"
|
||||
|
||||
full_key = self._key(key)
|
||||
if self.backend_type == "redis":
|
||||
data = await self.redis.get(full_key)
|
||||
# JSON.GET also reads existing JSON scalar values without migration.
|
||||
try:
|
||||
data = await self.redis.execute_command("JSON.GET", full_key)
|
||||
except ResponseError as exc:
|
||||
if not str(exc).startswith("WRONGTYPE"):
|
||||
raise
|
||||
try:
|
||||
data = await self.redis.get(full_key)
|
||||
except ResponseError as string_error:
|
||||
if not str(string_error).startswith("WRONGTYPE"):
|
||||
raise
|
||||
items = await self.redis.lrange(full_key, 0, -1)
|
||||
return [item.decode("utf-8") if isinstance(item, bytes) else item for item in items] or None
|
||||
if data is None:
|
||||
return None
|
||||
try:
|
||||
# Raw strings must not be JSON-decoded: '123' remains a string.
|
||||
return data.decode("utf-8") if isinstance(data, bytes) else data
|
||||
except UnicodeDecodeError:
|
||||
# Old binary Pickle entries are cache misses, never deserialized.
|
||||
return None
|
||||
else:
|
||||
expire = self._expiry.get(full_key)
|
||||
if expire and expire < asyncio.get_event_loop().time():
|
||||
expiry = self._expiry.get(full_key)
|
||||
if expiry is not None and expiry <= asyncio.get_running_loop().time():
|
||||
self._store.pop(full_key, None)
|
||||
self._expiry.pop(full_key, None)
|
||||
return None
|
||||
data = self._store.get(full_key)
|
||||
|
||||
return await self.load_object(data)
|
||||
return None if data is None else json.loads(data)
|
||||
|
||||
async def delete(self, key):
|
||||
full_key = f"{self.prefix}{key}"
|
||||
full_key = self._key(key)
|
||||
if self.backend_type == "redis":
|
||||
return await self.redis.delete(full_key)
|
||||
else:
|
||||
self._store.pop(full_key, None)
|
||||
self._expiry.pop(full_key, None)
|
||||
return True
|
||||
self._store.pop(full_key, None)
|
||||
self._expiry.pop(full_key, None)
|
||||
return True
|
||||
|
||||
async def clear(self):
|
||||
if self.backend_type == "redis":
|
||||
keys = await self.redis.keys(f"{self.prefix}*")
|
||||
if keys:
|
||||
return await self.redis.delete(*keys)
|
||||
return 0
|
||||
else:
|
||||
self._store.clear()
|
||||
self._expiry.clear()
|
||||
return True
|
||||
count = 0
|
||||
async for key in self.redis.scan_iter(match=f"{self.prefix}*"):
|
||||
count += await self.redis.delete(key)
|
||||
return count
|
||||
self._store.clear()
|
||||
self._expiry.clear()
|
||||
return True
|
||||
|
||||
+1
-1
Submodule quart_common updated: 53b7599193...bfaf49a304
+6
-1
@@ -129,9 +129,14 @@ async def file_delete_api(file_id, user):
|
||||
await current_app.convex.delete_file(file_id=file_id, user_id=user["sub"])
|
||||
return jsonify({"ok": True})
|
||||
|
||||
@side_main_bp.route("/-<file_id>")
|
||||
@side_main_bp.route("/-<path:file_id>")
|
||||
@LIMITER.limit("20 per minute;500 per hour;")
|
||||
async def serve_file(file_id: str):
|
||||
# Quart has already decoded %20; discard prose accidentally pasted after a link.
|
||||
file_id = file_id.split(" ", 1)[0]
|
||||
if not file_id or "/" in file_id:
|
||||
abort(404)
|
||||
|
||||
add_wide_event_context(nanoshare={"operation": "serve_file", "file_id": file_id})
|
||||
file_data = await current_app.convex.get_file(file_id=file_id)
|
||||
disable_logging = False
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from quart import Quart
|
||||
|
||||
def load_share_module(monkeypatch):
|
||||
stubs = {
|
||||
'my_modules.decoratory.header': {'login_required': lambda fn: fn},
|
||||
'my_modules.functions': {'get_ip': lambda: '192.0.2.1'},
|
||||
'my_modules.app.setup': {
|
||||
'LIMITER': types.SimpleNamespace(limit=lambda *args, **kwargs: lambda fn: fn),
|
||||
},
|
||||
'my_modules.app.logger': {'logger': None},
|
||||
'my_modules.expiry': {'parse_expires': lambda value: value},
|
||||
'quart_common.web.wide_event': {'add_wide_event_context': lambda **kwargs: None},
|
||||
}
|
||||
for name, values in stubs.items():
|
||||
module = types.ModuleType(name)
|
||||
module.__dict__.update(values)
|
||||
monkeypatch.setitem(sys.modules, name, module)
|
||||
|
||||
path = Path(__file__).resolve().parents[1] / 'routes/side/main.py'
|
||||
spec = importlib.util.spec_from_file_location('share_route_under_test', path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
@pytest.mark.parametrize('suffix', [
|
||||
'',
|
||||
' in female shape like https:/example.com/-kzmIlsf7uUe',
|
||||
'%20in%20female%20shape%20like%20https:/example.com/-kzmIlsf7uUe',
|
||||
])
|
||||
def test_share_link_looks_up_only_id(monkeypatch, suffix):
|
||||
async def run_test():
|
||||
module = load_share_module(monkeypatch)
|
||||
app = Quart(__name__)
|
||||
app.secret_key = 'test-secret'
|
||||
app.convex = types.SimpleNamespace(get_file=AsyncMock(return_value=None))
|
||||
app.register_blueprint(module.side_main_bp)
|
||||
|
||||
response = await app.test_client().get('/-eluTduc-npP' + suffix)
|
||||
|
||||
assert response.status_code == 404
|
||||
app.convex.get_file.assert_awaited_once_with(file_id='eluTduc-npP')
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
@pytest.mark.parametrize('path', ['/-/other', '/-%20text', '/-file/other'])
|
||||
def test_invalid_share_id_does_not_query_storage(monkeypatch, path):
|
||||
async def run_test():
|
||||
module = load_share_module(monkeypatch)
|
||||
app = Quart(__name__)
|
||||
app.convex = types.SimpleNamespace(get_file=AsyncMock())
|
||||
app.register_blueprint(module.side_main_bp)
|
||||
|
||||
response = await app.test_client().get(path)
|
||||
|
||||
assert response.status_code == 404
|
||||
app.convex.get_file.assert_not_awaited()
|
||||
|
||||
asyncio.run(run_test())
|
||||
Reference in New Issue
Block a user