|
25 | 25 | from botocore.exceptions import ClientError |
26 | 26 | from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Query, Depends, Cookie, Request |
27 | 27 | from fastapi.staticfiles import StaticFiles |
28 | | -from fastapi.responses import RedirectResponse, FileResponse, StreamingResponse, JSONResponse |
| 28 | +from fastapi.responses import RedirectResponse, FileResponse, StreamingResponse, JSONResponse, HTMLResponse |
29 | 29 | import pyotp |
30 | 30 | from passlib.hash import bcrypt |
31 | 31 | from pydantic import BaseModel |
@@ -294,7 +294,7 @@ async def s3_error_handler(request, exc): |
294 | 294 |
|
295 | 295 |
|
296 | 296 | _app_start_time = time.time() |
297 | | -SAIRO_VERSION = "3.6.1" |
| 297 | +SAIRO_VERSION = "3.6.2" |
298 | 298 |
|
299 | 299 |
|
300 | 300 | def _version_gt(a: str, b: str) -> bool: |
@@ -6889,15 +6889,69 @@ def bucket_info_compat(user: dict = Depends(get_current_user)): |
6889 | 6889 |
|
6890 | 6890 |
|
6891 | 6891 | # ── Serve React SPA ───────────────────────────────────────────────────────── |
| 6892 | +# ── White-label branding injection (pure, unit-testable) ───────────────────── |
| 6893 | + |
| 6894 | +def _brand_name_color(): |
| 6895 | + return (os.environ.get("APP_NAME", "Sairo"), |
| 6896 | + os.environ.get("PRIMARY_COLOR", "#3b82f6")) |
| 6897 | + |
| 6898 | +def _apply_branding_html(doc: str, name: str, color: str) -> str: |
| 6899 | + """Rewrite the brandable fields of index.html — tab title, og:title, |
| 6900 | + description, theme-color — from APP_NAME / PRIMARY_COLOR. Done server-side so |
| 6901 | + a white-label deployment is correct the instant the page loads (no client |
| 6902 | + flash from "Sairo", and correct og:title for link/social previews).""" |
| 6903 | + import re, html as _html |
| 6904 | + n = _html.escape(name, quote=True) |
| 6905 | + c = _html.escape(color, quote=True) |
| 6906 | + doc = re.sub(r"<title>.*?</title>", f"<title>{n}</title>", doc, count=1, flags=re.S) |
| 6907 | + doc = re.sub(r'(<meta property="og:title" content=")[^"]*(")', rf"\g<1>{n}\g<2>", doc) |
| 6908 | + doc = re.sub(r'(<meta name="description" content=")Sairo\b', rf"\g<1>{n}", doc) |
| 6909 | + doc = re.sub(r'(<meta name="theme-color" content=")[^"]*(")', rf"\g<1>{c}\g<2>", doc) |
| 6910 | + return doc |
| 6911 | + |
| 6912 | +def _branded_manifest(m: dict, name: str, color: str) -> dict: |
| 6913 | + """PWA manifest branded from APP_NAME / PRIMARY_COLOR.""" |
| 6914 | + m = dict(m) |
| 6915 | + m["name"] = name |
| 6916 | + m["short_name"] = name |
| 6917 | + if color: |
| 6918 | + m["theme_color"] = color |
| 6919 | + return m |
| 6920 | + |
| 6921 | + |
6892 | 6922 | static_dir = os.path.join(os.path.dirname(__file__), "static") |
6893 | 6923 | if os.path.isdir(static_dir): |
6894 | 6924 | app.mount("/assets", StaticFiles(directory=os.path.join(static_dir, "assets")), name="assets") |
6895 | 6925 |
|
| 6926 | + _spa_cache: dict = {} |
| 6927 | + |
| 6928 | + def _spa_index_html(): |
| 6929 | + if "html" not in _spa_cache: |
| 6930 | + try: |
| 6931 | + with open(os.path.join(static_dir, "index.html"), encoding="utf-8") as f: |
| 6932 | + _spa_cache["html"] = _apply_branding_html(f.read(), *_brand_name_color()) |
| 6933 | + except Exception: |
| 6934 | + _spa_cache["html"] = None |
| 6935 | + return _spa_cache["html"] |
| 6936 | + |
| 6937 | + @app.get("/manifest.json") |
| 6938 | + def serve_manifest(): |
| 6939 | + try: |
| 6940 | + with open(os.path.join(static_dir, "manifest.json"), encoding="utf-8") as f: |
| 6941 | + m = json.load(f) |
| 6942 | + except Exception: |
| 6943 | + m = {"start_url": "/", "display": "standalone"} |
| 6944 | + return JSONResponse(_branded_manifest(m, *_brand_name_color())) |
| 6945 | + |
6896 | 6946 | @app.get("/{path:path}") |
6897 | 6947 | def serve_spa(path: str): |
6898 | 6948 | file_path = os.path.realpath(os.path.join(static_dir, path)) |
6899 | 6949 | if not file_path.startswith(os.path.realpath(static_dir)): |
6900 | 6950 | raise HTTPException(403, "Forbidden") |
6901 | 6951 | if os.path.isfile(file_path): |
6902 | 6952 | return FileResponse(file_path) |
| 6953 | + # SPA entry: serve index.html with the app name/colour baked in. |
| 6954 | + doc = _spa_index_html() |
| 6955 | + if doc is not None: |
| 6956 | + return HTMLResponse(doc) |
6903 | 6957 | return FileResponse(os.path.join(static_dir, "index.html")) |
0 commit comments