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
70 lines
1.9 KiB
Python
70 lines
1.9 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Awaitable, Callable
|
|
|
|
from .linkify import tokenize
|
|
from .own_links import parse_share_link
|
|
|
|
# Each resolved link costs one backend lookup, so a single document stays bounded.
|
|
MAX_LOOKUPS = 20
|
|
|
|
def _media_kind(content_type: str) -> str:
|
|
value = (content_type or '').split(';')[0].strip().lower()
|
|
for prefix in ('image', 'video', 'audio'):
|
|
if value.startswith(prefix + '/'):
|
|
return prefix
|
|
if 'pdf' in value:
|
|
return 'pdf'
|
|
return 'other'
|
|
|
|
async def build_segments(
|
|
text: str,
|
|
host: str,
|
|
root_path: str,
|
|
lookup: Callable[[str], Awaitable[dict | None]] | None = None,
|
|
max_lookups: int = MAX_LOOKUPS,
|
|
) -> list[dict]:
|
|
"""Turn raw text into template segments: plain text, links and resolved own files."""
|
|
segments: list[dict] = []
|
|
resolved: dict[str, dict | None] = {}
|
|
|
|
for kind, value in tokenize(text):
|
|
if kind == 'text':
|
|
segments.append({'kind': 'text', 'text': value})
|
|
continue
|
|
|
|
file_id = parse_share_link(value, host=host, root_path=root_path) if lookup else None
|
|
if file_id is None:
|
|
segments.append({'kind': 'link', 'url': value})
|
|
continue
|
|
|
|
if file_id not in resolved:
|
|
if len(resolved) >= max_lookups:
|
|
segments.append({'kind': 'link', 'url': value})
|
|
continue
|
|
resolved[file_id] = await lookup(file_id)
|
|
|
|
file_data = resolved[file_id]
|
|
if not file_data:
|
|
segments.append({'kind': 'link', 'url': value, 'state': 'missing'})
|
|
continue
|
|
if file_data.get('expired'):
|
|
segments.append({
|
|
'kind': 'link',
|
|
'url': value,
|
|
'state': 'expired',
|
|
'label': file_data.get('file_name') or file_id,
|
|
})
|
|
continue
|
|
|
|
segments.append({
|
|
'kind': 'file',
|
|
'url': value,
|
|
'state': 'ok',
|
|
'file_id': file_id,
|
|
'label': file_data.get('file_name') or file_id,
|
|
'media': _media_kind(file_data.get('content_type') or ''),
|
|
})
|
|
|
|
return segments
|