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
+170
View File
@@ -0,0 +1,170 @@
import asyncio
import importlib.util
import sys
import types
from io import BytesIO
from pathlib import Path
from unittest.mock import AsyncMock
import pytest
from quart import Blueprint, Quart
ROOT = Path(__file__).resolve().parents[1]
BROWSER_ACCEPT = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
PASTE = (
'Ref Barend: https://i.example.dev/-Q6jmHoRYjFi\n'
'Foreign: https://other.example.dev/-abc\n'
'Script test: <script>alert(1)</script>\n'
)
FILES = {
'paste': {
'file_id': 'paste',
'file_name': 'pasted-2026-10-25T101010Z.txt',
'content_type': 'text/plain',
'db_image_url': 'storage://paste',
'uploaded_at': 1700000000000,
'user_id': 'owner',
},
'Q6jmHoRYjFi': {
'file_id': 'Q6jmHoRYjFi',
'file_name': 'barend.png',
'content_type': 'image/png',
'db_image_url': 'storage://barend',
'uploaded_at': 1700000000000,
'user_id': 'owner',
},
}
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 = ROOT / 'routes/side/main.py'
spec = importlib.util.spec_from_file_location('text_view_route_under_test', path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def build_app(monkeypatch, paste_body: bytes = PASTE.encode()):
module = load_share_module(monkeypatch)
app = Quart(
'text_view_test',
template_folder=str(ROOT / 'templates/side'),
static_folder=str(ROOT / 'templates/static'),
)
app.secret_key = 'test-secret'
async def get_file(file_id):
return FILES.get(file_id)
async def get_from_storage(storage_id):
return BytesIO(paste_body if storage_id == 'storage://paste' else b'binary')
app.convex = types.SimpleNamespace(
get_file=AsyncMock(side_effect=get_file),
get_from_storage=AsyncMock(side_effect=get_from_storage),
add_file_access=AsyncMock(return_value=None),
)
app.register_blueprint(module.side_main_bp)
# Minimal endpoints so base.htm/navigation.htm can resolve their url_for calls.
stub_bp = Blueprint('basic', __name__)
stub_bp.add_url_rule('/favicon.ico', 'favicon', lambda: '')
app.register_blueprint(stub_bp)
auth_bp = Blueprint('auth_login', __name__)
auth_bp.add_url_rule('/login', 'login', lambda: '')
app.register_blueprint(auth_bp)
composer_bp = Blueprint('link_composer', __name__)
composer_bp.add_url_rule('/links', 'link_composer_page', lambda: '')
app.register_blueprint(composer_bp)
return app
def get(app, path, headers=None):
async def run():
return await app.test_client().get(path, headers=headers or {})
return asyncio.run(run())
async def body_of(response):
return (await response.get_data()).decode()
def text_of(response):
return asyncio.run(body_of(response))
# --- browser view -------------------------------------------------------------
def test_browser_gets_html_with_clickable_links(monkeypatch):
app = build_app(monkeypatch)
response = get(app, '/-paste', {'Accept': BROWSER_ACCEPT, 'Host': 'i.example.dev'})
assert response.status_code == 200
assert response.content_type.startswith('text/html')
html = text_of(response)
assert '<a href="https://other.example.dev/-abc"' in html
assert 'rel="noopener noreferrer"' in html
def test_own_link_resolves_to_filename_and_thumbnail(monkeypatch):
app = build_app(monkeypatch)
html = text_of(get(app, '/-paste', {'Accept': BROWSER_ACCEPT, 'Host': 'i.example.dev'}))
assert 'barend.png' in html
assert '<img src="https://i.example.dev/-Q6jmHoRYjFi"' in html
def test_text_view_escapes_html_in_the_paste(monkeypatch):
app = build_app(monkeypatch)
html = text_of(get(app, '/-paste', {'Accept': BROWSER_ACCEPT, 'Host': 'i.example.dev'}))
assert '<script>alert(1)</script>' not in html
assert '&lt;script&gt;' in html
def test_text_view_renders_large_pastes_completely(monkeypatch):
marker = 'END-OF-PASTE-MARKER'
body = (('filler line\n' * 60_000) + marker).encode()
app = build_app(monkeypatch, paste_body=body)
html = text_of(get(app, '/-paste', {'Accept': BROWSER_ACCEPT, 'Host': 'i.example.dev'}))
assert marker in html
assert html.count('filler line') == 60_000
assert 'truncated' not in html.lower()
def test_text_view_is_not_cached(monkeypatch):
app = build_app(monkeypatch)
response = get(app, '/-paste', {'Accept': BROWSER_ACCEPT, 'Host': 'i.example.dev'})
assert response.headers['Cache-Control'] == 'private, no-store'
# --- raw fallbacks ------------------------------------------------------------
@pytest.mark.parametrize('path,headers', [
('/-paste', {'Accept': '*/*'}),
('/-paste', {'User-Agent': 'curl/8.0'}),
('/-paste?raw=1', {'Accept': BROWSER_ACCEPT}),
('/-paste?download=1', {'Accept': BROWSER_ACCEPT}),
])
def test_non_browser_requests_still_get_raw_text(monkeypatch, path, headers):
app = build_app(monkeypatch)
headers.setdefault('Host', 'i.example.dev')
response = get(app, path, headers)
assert response.status_code == 200
assert response.content_type.startswith('text/plain')
assert text_of(response) == PASTE
def test_binary_files_are_untouched(monkeypatch):
app = build_app(monkeypatch)
response = get(app, '/-Q6jmHoRYjFi', {'Accept': BROWSER_ACCEPT, 'Host': 'i.example.dev'})
assert response.status_code == 200
assert response.content_type.startswith('image/png')