feat(files): cache served files and add HTML text viewer
Build and Push Docker Container / build-and-push (push) Successful in 2m37s

File cache:
- Add my_modules/filecache with metadata, blob, validator and store layers
- Cache convex.get_file via AsyncCache, TTL clamped to the file expiry
- Skip caching for missing, incomplete or already expired records
- Add a process-local, size-bounded LRU for immutable storage blobs
- Build the cache stack from FILE_CACHE_* env vars in build_file_cache
- Attach the cache to the app in my_modules/app/setup.py
- Expose app-bound helpers in filecache/access.py for routes

Serving:
- Send strong ETag, Last-Modified and Cache-Control on served files
- Answer If-None-Match and If-Modified-Since with 304 from metadata only
- Avoid pulling the blob from Convex on a revalidation hit
- Add a short Cloudflare-CDN-Cache-Control edge window for deleted files
- Record cache_status in the wide event on a 304
- Invalidate cached metadata after file update and delete in both routes

Text viewer:
- Add my_modules/textview to render text/plain and text/markdown as HTML
- Linkify http(s) URLs and trim trailing sentence punctuation
- Resolve links pointing at this instance to the referenced file
- Inline image previews, mark missing and expired targets
- Cap resolution at MAX_LOOKUPS backend lookups per document
- Serve the viewer only for real browser navigations, never for curl or CLI
- Keep raw bytes for ?raw=1, downloads and Accept headers without text/html
- Add templates/side/views/webpage/files/text_view.htm with a raw link

Other:
- Add tests for cache layers, serving, text rendering and the route
- Bump multidict to 7.2.1 in uv.lock
- Update the my_helpers submodule pointer
This commit is contained in:
2026-10-25 18:54:46 +01:00
parent f6a70b5ced
commit 3db374b1ab
21 changed files with 1692 additions and 97 deletions
+27
View File
@@ -0,0 +1,27 @@
from .blobs import BlobCache
from .metadata import FileMetadataCache
# access.py is imported directly by routes: it needs a Quart app context.
from .store import FileCache, build_file_cache
from .validators import (
build_etag,
cache_control,
edge_cache_control,
etag_matches,
is_fresh,
not_modified_since,
remaining_seconds,
)
__all__ = [
'BlobCache',
'FileCache',
'FileMetadataCache',
'build_etag',
'build_file_cache',
'cache_control',
'edge_cache_control',
'etag_matches',
'is_fresh',
'not_modified_since',
'remaining_seconds',
]
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
from quart import current_app
DEFAULT_BROWSER_MAX_AGE = 86400
DEFAULT_EDGE_MAX_AGE = 1800
def file_cache():
"""The app's FileCache, or None when caching is not configured."""
return getattr(current_app, 'file_cache', None)
def browser_max_age() -> int:
cache = file_cache()
return DEFAULT_BROWSER_MAX_AGE if cache is None else cache.blob_max_age
def edge_max_age() -> int:
cache = file_cache()
return DEFAULT_EDGE_MAX_AGE if cache is None else cache.edge_max_age
async def load_file(file_id: str):
async def loader():
return await current_app.convex.get_file(file_id=file_id)
cache = file_cache()
if cache is None:
return await loader()
return await cache.get_metadata(file_id, loader)
async def load_blob(storage_key: str) -> bytes:
async def loader() -> bytes:
payload = await current_app.convex.get_from_storage(storage_key)
return payload.getvalue() if hasattr(payload, 'getvalue') else bytes(payload)
cache = file_cache()
if cache is None:
return await loader()
return await cache.get_blob(storage_key, loader)
async def drop_file_cache(file_id: str) -> None:
"""Invalidate metadata after a mutation; blobs are immutable and expire on TTL."""
cache = file_cache()
if cache is not None:
await cache.invalidate(file_id)
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import time
from collections import OrderedDict
class BlobCache:
"""Process-local, size-bounded LRU for file bytes.
Deliberately not AsyncCache: that layer JSON-encodes values (binary would need
base64) and its memory backend evicts only on read, so blobs could grow without
bound. Entries are immutable storage blobs, so a stale read is impossible.
"""
def __init__(self, max_total_bytes: int, max_item_bytes: int, ttl: int = 300):
self._max_total = max(0, int(max_total_bytes))
self._max_item = max(0, int(max_item_bytes))
self._ttl = max(1, int(ttl))
self._entries: OrderedDict[str, tuple[bytes, float]] = OrderedDict()
self._total = 0
self.hits = 0
self.misses = 0
@property
def enabled(self) -> bool:
return self._max_total > 0 and self._max_item > 0
@property
def current_bytes(self) -> int:
return self._total
def _drop(self, key: str) -> None:
payload = self._entries.pop(key, None)
if payload is not None:
self._total -= len(payload[0])
def get(self, key: str, now: float | None = None) -> bytes | None:
entry = self._entries.get(key)
if entry is None:
self.misses += 1
return None
data, expires_at = entry
if (now if now is not None else time.monotonic()) >= expires_at:
self._drop(key)
self.misses += 1
return None
self._entries.move_to_end(key)
self.hits += 1
return data
def set(self, key: str, data: bytes, now: float | None = None) -> bool:
if not self.enabled or len(data) > self._max_item:
return False
self._drop(key)
self._entries[key] = (data, (now if now is not None else time.monotonic()) + self._ttl)
self._total += len(data)
while self._total > self._max_total and self._entries:
oldest = next(iter(self._entries))
self._drop(oldest)
return True
def invalidate(self, key: str) -> None:
self._drop(key)
def clear(self) -> None:
self._entries.clear()
self._total = 0
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from .validators import remaining_seconds
class FileMetadataCache:
"""Cache-aside layer for convex.get_file, shared across workers via AsyncCache.
Only complete, non-expired records are cached. Misses and expired markers stay
uncached so a newly uploaded or just-expired file is never masked.
"""
def __init__(self, cache, ttl: int = 60, prefix: str = 'nanoshare:filemeta:'):
self._cache = cache
self._ttl = max(1, int(ttl))
self._prefix = prefix
self.hits = 0
self.misses = 0
def _key(self, file_id: str) -> str:
return f'{self._prefix}{file_id}'
def _effective_ttl(self, file_data: dict) -> int:
"""Never hold a record past the moment it expires."""
remaining = remaining_seconds(file_data.get('expires_at'))
if remaining is None:
return self._ttl
return min(self._ttl, remaining)
def _is_cacheable(self, file_data) -> bool:
if not isinstance(file_data, dict) or not file_data:
return False
if file_data.get('expired'):
return False
return bool(file_data.get('db_image_url'))
async def get(self, file_id: str, loader: Callable[[], Awaitable[dict | None]]) -> dict | None:
cached = await self._cache.get(self._key(file_id))
if isinstance(cached, dict) and cached:
self.hits += 1
return cached
self.misses += 1
file_data = await loader()
if self._is_cacheable(file_data):
ttl = self._effective_ttl(file_data)
if ttl > 0:
await self._cache.set(self._key(file_id), file_data, ttl=ttl)
return file_data
async def invalidate(self, file_id: str) -> None:
await self._cache.delete(self._key(file_id))
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
import os
from collections.abc import Awaitable, Callable
from .blobs import BlobCache
from .metadata import FileMetadataCache
def _env_int(name: str, default: int) -> int:
try:
return int(os.getenv(name, str(default)))
except (TypeError, ValueError):
return default
class FileCache:
"""Facade over the metadata cache (shared) and the blob cache (process-local)."""
def __init__(self, metadata: FileMetadataCache, blobs: BlobCache, blob_max_age: int, edge_max_age: int):
self.metadata = metadata
self.blobs = blobs
self.blob_max_age = blob_max_age
self.edge_max_age = edge_max_age
async def get_metadata(self, file_id: str, loader: Callable[[], Awaitable[dict | None]]):
return await self.metadata.get(file_id, loader)
async def get_blob(self, storage_key: str, loader: Callable[[], Awaitable[bytes]]) -> bytes:
cached = self.blobs.get(storage_key)
if cached is not None:
return cached
data = await loader()
self.blobs.set(storage_key, data)
return data
async def invalidate(self, file_id: str, storage_key: str | None = None) -> None:
await self.metadata.invalidate(file_id)
if storage_key:
self.blobs.invalidate(storage_key)
def stats(self) -> dict:
return {
'metadata_hits': self.metadata.hits,
'metadata_misses': self.metadata.misses,
'blob_hits': self.blobs.hits,
'blob_misses': self.blobs.misses,
'blob_bytes': self.blobs.current_bytes,
}
def build_file_cache(cache) -> FileCache:
"""Build the cache stack from FILE_CACHE_* environment settings."""
return FileCache(
metadata=FileMetadataCache(cache, ttl=_env_int('FILE_CACHE_METADATA_TTL', 60)),
blobs=BlobCache(
max_total_bytes=_env_int('FILE_CACHE_BLOB_TOTAL_BYTES', 64 * 1024 * 1024),
max_item_bytes=_env_int('FILE_CACHE_BLOB_ITEM_BYTES', 2 * 1024 * 1024),
ttl=_env_int('FILE_CACHE_BLOB_TTL', 300),
),
blob_max_age=_env_int('FILE_CACHE_BROWSER_MAX_AGE', 86400),
# Cloudflare keeps serving deleted files, so the edge window stays short.
edge_max_age=_env_int('FILE_CACHE_EDGE_MAX_AGE', 1800),
)
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
import hashlib
import time
from email.utils import parsedate_to_datetime
def build_etag(file_id: str, file_data: dict, download: bool = False) -> str:
"""Strong validator for a stored file.
The blob behind a file_id never changes (updateFile only patches metadata),
so uploaded_at plus the storage id identify the bytes. The download flag is
part of the tag because it changes Content-Disposition.
"""
parts = [
str(file_id),
str(file_data.get('uploaded_at', '')),
str(file_data.get('db_image_url', '')),
str(file_data.get('file_name', '')),
'attachment' if download else 'inline',
]
digest = hashlib.sha256('\x1f'.join(parts).encode('utf-8')).hexdigest()[:32]
return f'"{digest}"'
def _split_etags(header: str) -> list[str]:
return [value.strip() for value in header.split(',') if value.strip()]
def etag_matches(if_none_match: str | None, etag: str) -> bool:
"""RFC 9110 If-None-Match: '*' matches, weak comparison otherwise."""
if not if_none_match or not etag:
return False
header = if_none_match.strip()
if header == '*':
return True
def normalize(value: str) -> str:
return value.removeprefix('W/')
target = normalize(etag)
return any(normalize(candidate) == target for candidate in _split_etags(header))
def _parse_http_date(value: str):
try:
return parsedate_to_datetime(value)
except (TypeError, ValueError):
return None
def not_modified_since(if_modified_since: str | None, last_modified_epoch: float | None) -> bool:
if not if_modified_since or last_modified_epoch is None:
return False
parsed = _parse_http_date(if_modified_since)
if parsed is None:
return False
# HTTP dates have second precision, so compare truncated values.
return int(last_modified_epoch) <= int(parsed.timestamp())
def is_fresh(
if_none_match: str | None,
if_modified_since: str | None,
etag: str,
last_modified_epoch: float | None,
) -> bool:
"""Decide a 304 from metadata alone, before any blob is fetched."""
if if_none_match is not None:
# An ETag was offered, so it alone decides and the date is ignored.
return etag_matches(if_none_match, etag)
return not_modified_since(if_modified_since, last_modified_epoch)
def remaining_seconds(expires_at, now_ms: float | None = None) -> int | None:
"""Seconds until expires_at (epoch ms), or None when the file never expires."""
if expires_at in (None, '', 0):
return None
try:
expires_ms = float(expires_at)
except (TypeError, ValueError):
return None
if now_ms is None:
now_ms = time.time() * 1000
return int((expires_ms - now_ms) / 1000)
def _clamped_max_age(expires_at, max_age: int) -> int:
if max_age <= 0:
return 0
remaining = remaining_seconds(expires_at)
if remaining is None:
return max_age
return min(max_age, max(0, remaining))
def cache_control(expires_at, max_age: int) -> str:
"""Browser caching for immutable blobs, clamped to the remaining lifetime."""
clamped = _clamped_max_age(expires_at, max_age)
return f'public, max-age={clamped}' if clamped > 0 else 'no-cache'
def edge_cache_control(expires_at, max_age: int) -> str:
"""Separate, deliberately short TTL for Cloudflare.
Shared caches keep serving a file after it was deleted or expired at the
origin, so the edge gets a much smaller window than the browser does.
"""
clamped = _clamped_max_age(expires_at, max_age)
return f'public, max-age={clamped}' if clamped > 0 else 'no-store'