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:
@@ -8,6 +8,7 @@ from my_modules.functions import (
|
||||
from my_modules.app.constens import SECRET_KEY, THE_IP_BOT_MANAGER
|
||||
from my_modules.OrphanStorageIdRegistry import OrphanStorageIdRegistry
|
||||
from my_modules.AsyncCache import AsyncCache
|
||||
from my_modules.filecache import build_file_cache
|
||||
from my_modules.app.logger import logger
|
||||
from quart_common.web.request import TRUST_PROXY_HEADERS, TRUSTED_PROXY_IPS, TRUSTED_PROXY_CIDRS
|
||||
from quart_common.web.wide_event import register_wide_event_logging
|
||||
@@ -104,6 +105,8 @@ app.orphan_storage_registry = OrphanStorageIdRegistry(
|
||||
redis_client=orphan_redis,
|
||||
)
|
||||
|
||||
app.file_cache = build_file_cache(cache)
|
||||
|
||||
@app.before_serving
|
||||
async def init_convex():
|
||||
await logger.info(
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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'
|
||||
@@ -0,0 +1,14 @@
|
||||
from .linkify import tokenize
|
||||
from .own_links import parse_share_link
|
||||
from .render import MAX_LOOKUPS, build_segments
|
||||
from .view import build_view_context, decode_text, wants_text_view
|
||||
|
||||
__all__ = [
|
||||
'MAX_LOOKUPS',
|
||||
'build_segments',
|
||||
'build_view_context',
|
||||
'decode_text',
|
||||
'parse_share_link',
|
||||
'tokenize',
|
||||
'wants_text_view',
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# Only http(s) is linkified; everything else stays inert plain text.
|
||||
_URL_PATTERN = re.compile(r'https?://[^\s<>"\'`]+', re.IGNORECASE)
|
||||
|
||||
# Prose punctuation that is almost never part of the URL itself.
|
||||
_TRAILING_PUNCTUATION = '.,;:!?\'"'
|
||||
_BRACKET_PAIRS = {')': '(', ']': '[', '}': '{'}
|
||||
|
||||
def _trim_trailing(url: str) -> str:
|
||||
"""Give punctuation that belongs to the surrounding sentence back to the text."""
|
||||
while url:
|
||||
last = url[-1]
|
||||
if last in _TRAILING_PUNCTUATION:
|
||||
url = url[:-1]
|
||||
continue
|
||||
opening = _BRACKET_PAIRS.get(last)
|
||||
if opening is not None and url.count(opening) < url.count(last):
|
||||
url = url[:-1]
|
||||
continue
|
||||
break
|
||||
return url
|
||||
|
||||
def tokenize(text: str) -> list[tuple[str, str]]:
|
||||
"""Split text into ('text', value) and ('url', value) tokens, order preserved."""
|
||||
tokens: list[tuple[str, str]] = []
|
||||
cursor = 0
|
||||
|
||||
for match in _URL_PATTERN.finditer(text):
|
||||
url = _trim_trailing(match.group(0))
|
||||
if not url:
|
||||
continue
|
||||
|
||||
start = match.start()
|
||||
end = start + len(url)
|
||||
if start > cursor:
|
||||
tokens.append(('text', text[cursor:start]))
|
||||
tokens.append(('url', url))
|
||||
cursor = end
|
||||
|
||||
if cursor < len(text):
|
||||
tokens.append(('text', text[cursor:]))
|
||||
return tokens
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
_ALLOWED_SCHEMES = {'http', 'https'}
|
||||
|
||||
def _normalize_host(host: str) -> str:
|
||||
return (host or '').split(',')[0].strip().lower().rstrip('.')
|
||||
|
||||
def parse_share_link(url: str, host: str, root_path: str = '') -> str | None:
|
||||
"""Return the file_id when url points at this instance's /-<file_id> route."""
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if parts.scheme.lower() not in _ALLOWED_SCHEMES:
|
||||
return None
|
||||
if not parts.netloc or _normalize_host(parts.netloc) != _normalize_host(host):
|
||||
return None
|
||||
|
||||
prefix = (root_path or '').rstrip('/')
|
||||
path = parts.path
|
||||
if prefix:
|
||||
if not path.startswith(prefix + '/'):
|
||||
return None
|
||||
path = path[len(prefix):]
|
||||
|
||||
if not path.startswith('/-'):
|
||||
return None
|
||||
|
||||
file_id = path[2:]
|
||||
if not file_id or '/' in file_id:
|
||||
return None
|
||||
return file_id
|
||||
@@ -0,0 +1,69 @@
|
||||
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
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from .render import build_segments
|
||||
|
||||
# Text types that get the rendered viewer; anything else is served untouched.
|
||||
VIEWABLE_CONTENT_TYPES = {'text/plain', 'text/markdown'}
|
||||
|
||||
_TRUTHY = {'1', 'true', 'yes'}
|
||||
|
||||
def _base_content_type(content_type: str) -> str:
|
||||
return (content_type or '').split(';')[0].strip().lower()
|
||||
|
||||
def wants_text_view(content_type: str, accept_header: str, raw_param: str | None, force_download: bool) -> bool:
|
||||
"""Only real browser navigations get HTML; curl, wget and the CLI keep the raw bytes."""
|
||||
if force_download or (raw_param or '').lower() in _TRUTHY:
|
||||
return False
|
||||
if _base_content_type(content_type) not in VIEWABLE_CONTENT_TYPES:
|
||||
return False
|
||||
# Accept: */* (curl, wget, CLI clients) must not trigger the viewer.
|
||||
return 'text/html' in (accept_header or '').lower()
|
||||
|
||||
def decode_text(raw: bytes) -> str:
|
||||
"""Decode the full document; invalid bytes become replacement characters."""
|
||||
return raw.decode('utf-8', errors='replace')
|
||||
|
||||
async def build_view_context(
|
||||
raw: bytes,
|
||||
file_name: str,
|
||||
host: str,
|
||||
root_path: str,
|
||||
raw_url: str,
|
||||
lookup: Callable[[str], Awaitable[dict | None]] | None,
|
||||
) -> dict:
|
||||
text = decode_text(raw)
|
||||
segments = await build_segments(text, host=host, root_path=root_path, lookup=lookup)
|
||||
return {
|
||||
'file_name': file_name,
|
||||
'segments': segments,
|
||||
'raw_url': raw_url,
|
||||
}
|
||||
Reference in New Issue
Block a user