cdnV2

git clone https://github.com/notamitgamer/cdnV2.git
· rebuilt hourlyOpen on GitHub
root / app / fast_tree.py

fast_tree.py (5156B)


      1 """Data for instant folder navigation (static/fasttree.js).
      2 
      3 The browser keeps two things between visits:
      4 
      5 * the **tree**: every non-hidden file as ``[path, size]`` (``/api/tree``), small enough that the whole
      6   bucket's structure fits in a few kilobytes gzipped, and
      7 * the **shell**: a server-rendered folder page with *slots* where the folder-specific parts go, plus
      8   the HTML of each of those parts with ``@@placeholders@@`` (``/api/shell``).
      9 
     10 With both in hand a folder page can be assembled locally, with no request, and handed to Turbo. The
     11 markup comes from the same Jinja macros (templates/_listing.html) the server uses for ordinary
     12 renders, so the client never carries a second copy of it; only the data is filled in. See
     13 tests/test_fast_tree.py, which checks that a locally assembled page equals the server's.
     14 """
     15 
     16 import gzip
     17 import hashlib
     18 import json
     19 import os
     20 
     21 from markupsafe import Markup
     22 from starlette.requests import Request
     23 from starlette.responses import Response
     24 
     25 # Above this many files the tree is not shipped and the browser falls back to normal navigation.
     26 MAX_FILES = int(os.getenv("FAST_TREE_MAX_FILES", "20000"))
     27 
     28 # Markers left in the shell where the folder-specific HTML goes. Each must appear exactly once.
     29 SLOTS = (
     30     "<!--@root-header-->",
     31     "<!--@breadcrumb-->",
     32     "<!--@toolbar-->",
     33     "<!--@list-->",
     34     "<!--@stats-->",
     35     "/*@visit*/",
     36 )
     37 
     38 _tree_memo: tuple | None = None   # (items list, payload, etag): the items list is cached by storage, so identity is a cheap "unchanged" test
     39 _shell_memo: dict = {}
     40 
     41 
     42 def _digest(*chunks: str) -> str:
     43     h = hashlib.sha1()
     44     for c in chunks:
     45         h.update(c.encode("utf-8"))
     46     return h.hexdigest()[:16]
     47 
     48 
     49 def tree_payload(items: list) -> tuple[dict, str]:
     50     """(payload, etag) for /api/tree, built from storage's full tree listing."""
     51     global _tree_memo
     52     if _tree_memo is not None and _tree_memo[0] is items:
     53         return _tree_memo[1], _tree_memo[2]
     54 
     55     files = sorted([it["path"], int(it["size"] or 0)] for it in items if not it["is_dir"])
     56     etag = _digest(json.dumps(files, separators=(",", ":"), ensure_ascii=False))
     57     if len(files) > MAX_FILES:
     58         payload = {"v": etag, "n": len(files), "too_large": True}
     59     else:
     60         payload = {"v": etag, "n": len(files), "files": files}
     61     _tree_memo = (items, payload, etag)
     62     return payload, etag
     63 
     64 
     65 def shell_payload(env, ctx: dict) -> tuple[dict, str]:
     66     """(payload, etag) for /api/shell: the skeleton page and the fill-in fragments."""
     67     key = (ctx.get("cdn_base_url"), ctx.get("raw_base_url"))
     68     if key in _shell_memo:
     69         return _shell_memo[key]
     70 
     71     html = env.get_template("index.html").render(
     72         {**ctx, "page": "listing", "path": "", "items": [], "skeleton": True}
     73     )
     74     for slot in SLOTS:
     75         if html.count(slot) != 1:
     76             raise RuntimeError(f"shell slot {slot!r} should appear exactly once, found {html.count(slot)}")
     77 
     78     m = env.get_template("_listing.html").module
     79     parts = {
     80         "root_header": m.root_header(),
     81         "count": m.count("@@n@@", "@@word@@", "@@s@@"),
     82         "toolbar": m.toolbar("@@counts@@", "@@path@@"),
     83         "go_up": m.go_up("@@parent_path@@"),
     84         "row_dir": m.row_dir("@@name@@", "@@path@@"),
     85         "row_file": m.row_file("@@name@@", "@@path@@", "@@size_str@@"),
     86         "empty": m.empty_state(),
     87         "stats": m.footer_stats("@@n@@", "@@s@@", "@@size_str@@"),
     88         "crumb_container": m.crumb_container("@@inner@@", ctx["cdn_base_url"], "@@tail@@"),
     89         "crumb_link": m.crumb_link("@@href@@", "@@name@@"),
     90         "crumb_collapsed": m.crumb_collapsed("@@menu@@"),
     91         "copy_btn": m.copy_btn("@@path@@"),
     92         "visit": m.visit_script(Markup(
     93             'type: "folder",\n path: @@path_json@@,\n cdn_url: @@cdn_url_json@@,'
     94         )),
     95     }
     96     parts = {k: str(v) for k, v in parts.items()}
     97     payload = {"html": html, "parts": parts, "cdn_base": ctx["cdn_base_url"]}
     98     etag = _digest(html, json.dumps(parts, sort_keys=True), ctx["cdn_base_url"])
     99     payload["v"] = etag
    100     _shell_memo[key] = (payload, etag)
    101     return payload, etag
    102 
    103 
    104 def _etag_matches(header: str, etag: str) -> bool:
    105     # Proxies may turn a strong ETag into a weak one (W/"...") when they compress; accept either.
    106     for token in header.split(","):
    107         token = token.strip()
    108         if token.startswith("W/"):
    109             token = token[2:]
    110         if token == f'"{etag}"':
    111             return True
    112     return False
    113 
    114 
    115 def json_response(request: Request, payload: dict, etag: str) -> Response:
    116     """JSON with an ETag (so an unchanged tree costs a tiny 304) and gzip."""
    117     headers = {"ETag": f'"{etag}"', "Cache-Control": "no-cache", "Vary": "Accept-Encoding"}
    118     if _etag_matches(request.headers.get("if-none-match", ""), etag):
    119         return Response(status_code=304, headers=headers)
    120     body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
    121     if "gzip" in request.headers.get("accept-encoding", "").lower():
    122         body = gzip.compress(body, 6)
    123         headers["Content-Encoding"] = "gzip"
    124     return Response(body, media_type="application/json", headers=headers)