feat(files): cache served files and add HTML text viewer
Build and Push Docker Container / build-and-push (push) Successful in 2m37s
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:
@@ -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 '<script>' 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')
|
||||
Reference in New Issue
Block a user