217 lines
8.8 KiB
Python
217 lines
8.8 KiB
Python
"""Admin routes: index, favicon, browse-dirs, robots."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Body, HTTPException
|
|
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, Response
|
|
|
|
from core.constants import STATIC_DIR, __version__
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
_BROWSE_BLOCKED: frozenset[str] = frozenset({
|
|
"/proc", "/sys", "/dev", "/run", "/boot",
|
|
})
|
|
|
|
# Any folder a Browse dialog leads a user to pick only really "exists" for
|
|
# them if it survives the container being recreated and, ideally, is visible
|
|
# from their host machine too — i.e. it's on a bind/volume mount, not the
|
|
# container's own throwaway overlay filesystem. Read once from /proc/mounts
|
|
# (containers don't remount at runtime) rather than per-request: confirmed
|
|
# live as a real gap — a user picked a folder from the picker for the
|
|
# audiobook-export "Save Location" field with no way to tell it was actually
|
|
# just container-internal storage that would vanish and never show up on
|
|
# their host.
|
|
_PERSISTED_MOUNTS: frozenset[str] | None = None
|
|
_MOUNT_IGNORE_FSTYPES = frozenset({
|
|
"overlay", "proc", "sysfs", "cgroup", "cgroup2", "devpts", "mqueue",
|
|
"tmpfs", "devtmpfs", "securityfs", "debugfs", "tracefs", "pstore",
|
|
"bpf", "hugetlbfs", "autofs", "nsfs",
|
|
})
|
|
|
|
|
|
def _persisted_mounts() -> frozenset[str]:
|
|
global _PERSISTED_MOUNTS
|
|
if _PERSISTED_MOUNTS is not None:
|
|
return _PERSISTED_MOUNTS
|
|
mounts: set[str] = set()
|
|
try:
|
|
with open("/proc/mounts", "r", encoding="utf-8", errors="replace") as f:
|
|
for line in f:
|
|
parts = line.split()
|
|
if len(parts) < 3:
|
|
continue
|
|
mnt, fstype = parts[1], parts[2]
|
|
if fstype in _MOUNT_IGNORE_FSTYPES or mnt == "/":
|
|
continue
|
|
if any(mnt == b or mnt.startswith(b + "/") for b in _BROWSE_BLOCKED):
|
|
continue
|
|
# Individual files are sometimes bind-mounted too (this app's
|
|
# own dev setup does this for /etc/resolv.conf etc.) — those
|
|
# can never be "picked" as a folder and would otherwise mark
|
|
# their parent directory as a reachable waypoint that then
|
|
# dead-ends (confirmed live: /etc showed up pinned for this
|
|
# reason alone, with nothing browsable underneath it).
|
|
if not os.path.isdir(mnt):
|
|
continue
|
|
mounts.add(mnt)
|
|
except OSError as exc:
|
|
logger.warning("Could not read /proc/mounts for the folder browser: %s", exc)
|
|
_PERSISTED_MOUNTS = frozenset(mounts)
|
|
return _PERSISTED_MOUNTS
|
|
|
|
|
|
def _mount_relation(path_str: str, mounts: frozenset[str]) -> tuple[bool, bool]:
|
|
"""Returns (is_persisted, leads_to_persisted) for `path_str`.
|
|
is_persisted: the path itself is on (at or below) a real mount — safe to
|
|
actually save into. leads_to_persisted: an ancestor of some mount — not
|
|
itself persisted, but worth navigating into (e.g. /home on the way to
|
|
/home/app/.config/tts-voice-creator, which sits inside otherwise-ordinary
|
|
overlay directories)."""
|
|
is_persisted = any(path_str == m or path_str.startswith(m + "/") for m in mounts)
|
|
leads_to = is_persisted or any(m == path_str or m.startswith(path_str + "/") for m in mounts)
|
|
return is_persisted, leads_to
|
|
|
|
|
|
_ASSET_URL_RE = re.compile(r"(/static/[^\"'\s?]+)\?v=[^\"'\s&]+")
|
|
|
|
|
|
def asset_build_id() -> str:
|
|
"""A version string that changes whenever any script, stylesheet or module list changes.
|
|
|
|
Scripts and stylesheets are requested as ``file?v=<version>`` and cached for a year as
|
|
immutable, so a version that only moves at release time leaves a browser (or a proxy in front
|
|
of it) running old code after an edit -- a button added to a script simply does not appear.
|
|
The newest modification time under static/ js, css and the manifest is folded into the version.
|
|
"""
|
|
newest = 0
|
|
for pattern in ("js/*.js", "css/*.css", "*.css", "loader.js", "nav.js", "module-manifest.json"):
|
|
for path in STATIC_DIR.glob(pattern):
|
|
try:
|
|
newest = max(newest, int(path.stat().st_mtime))
|
|
except OSError:
|
|
pass
|
|
return f"{__version__}-{newest:x}"
|
|
|
|
|
|
@router.get("/")
|
|
async def index():
|
|
"""The app shell, with every /static asset URL carrying the current build id."""
|
|
html = (STATIC_DIR / "index.html").read_text("utf-8")
|
|
build = asset_build_id()
|
|
return HTMLResponse(_ASSET_URL_RE.sub(lambda m: f"{m.group(1)}?v={build}", html),
|
|
headers={"Cache-Control": "no-store, max-age=0"})
|
|
|
|
|
|
@router.get("/favicon.ico")
|
|
async def favicon():
|
|
return FileResponse(STATIC_DIR / "icon.svg", media_type="image/svg+xml")
|
|
|
|
|
|
# ── PWA: serve the service worker and manifest from root so the SW controls "/" ──
|
|
@router.get("/sw.js")
|
|
async def service_worker():
|
|
return FileResponse(
|
|
STATIC_DIR / "sw.js",
|
|
media_type="application/javascript",
|
|
headers={"Cache-Control": "no-cache", "Service-Worker-Allowed": "/"},
|
|
)
|
|
|
|
|
|
@router.get("/manifest.webmanifest")
|
|
async def manifest():
|
|
return FileResponse(STATIC_DIR / "manifest.webmanifest", media_type="application/manifest+json")
|
|
|
|
|
|
@router.get("/api/browse-dirs")
|
|
async def browse_dirs(path: str = "/"):
|
|
p = Path(path).resolve()
|
|
p_str = str(p)
|
|
if any(p_str == b or p_str.startswith(b + "/") for b in _BROWSE_BLOCKED):
|
|
raise HTTPException(403, "Access to this path is not permitted")
|
|
if not p.is_dir():
|
|
raise HTTPException(404, "Not a directory")
|
|
try:
|
|
all_dirs = sorted([d.name for d in p.iterdir() if d.is_dir()], key=str.lower)
|
|
entries = [n for n in all_dirs if not n.startswith(".")]
|
|
except PermissionError:
|
|
raise HTTPException(403, "Permission denied")
|
|
parent = str(p.parent) if p.parent != p else None
|
|
mounts = _persisted_mounts()
|
|
is_persisted, _ = _mount_relation(p_str, mounts)
|
|
# Additive fields only — existing callers (e.g. voice-library.js's own
|
|
# folder picker) read `dirs` as plain strings and are unaffected.
|
|
# Computed over ALL subdirectories, dotdirs included: the app's own
|
|
# default export location (~/.config/tts-voice-creator) sits behind a
|
|
# dotdir, so excluding those here would make it unreachable through
|
|
# mounted-only navigation even though it's the single most useful target
|
|
# (confirmed live) — `dirs` above still hides dotfiles for every other
|
|
# (unrestricted) use of this endpoint, unaffected.
|
|
reachable_dirs = []
|
|
persisted_dirs = []
|
|
for name in all_dirs:
|
|
child = p_str.rstrip("/") + "/" + name if p_str != "/" else "/" + name
|
|
child_persisted, child_leads_to = _mount_relation(child, mounts)
|
|
if child_leads_to:
|
|
reachable_dirs.append(name)
|
|
if child_persisted:
|
|
persisted_dirs.append(name)
|
|
return {
|
|
"path": p_str, "parent": parent, "dirs": entries,
|
|
"is_persisted": is_persisted,
|
|
"reachable_dirs": reachable_dirs,
|
|
"persisted_dirs": persisted_dirs,
|
|
}
|
|
|
|
|
|
_MKDIR_NAME_RE = re.compile(r"^[^/\\\x00]{1,100}$")
|
|
|
|
|
|
@router.post("/api/browse-dirs/mkdir")
|
|
async def browse_dirs_mkdir(payload: dict = Body(...)):
|
|
"""Create a subfolder from inside the folder browser modal — without
|
|
this, picking a destination that doesn't exist yet (e.g. a new export
|
|
folder) meant leaving the app to create it first (confirmed live)."""
|
|
path = str(payload.get("path") or "")
|
|
name = str(payload.get("name") or "").strip()
|
|
if not name or name in (".", "..") or not _MKDIR_NAME_RE.match(name):
|
|
raise HTTPException(400, "Invalid folder name")
|
|
p = Path(path).resolve()
|
|
p_str = str(p)
|
|
if any(p_str == b or p_str.startswith(b + "/") for b in _BROWSE_BLOCKED):
|
|
raise HTTPException(403, "Access to this path is not permitted")
|
|
if not p.is_dir():
|
|
raise HTTPException(404, "Not a directory")
|
|
new_dir = p / name
|
|
if new_dir.exists() and not new_dir.is_dir():
|
|
raise HTTPException(409, "A file with that name already exists here")
|
|
try:
|
|
new_dir.mkdir(exist_ok=True)
|
|
except PermissionError:
|
|
raise HTTPException(403, "Permission denied")
|
|
return {"ok": True, "path": str(new_dir)}
|
|
|
|
|
|
@router.get("/api/version")
|
|
async def get_version():
|
|
return {"version": __version__}
|
|
|
|
|
|
@router.get("/api/changelog", response_class=PlainTextResponse)
|
|
async def get_changelog():
|
|
root = Path(__file__).parent.parent
|
|
for cl in (root / "CHANGELOG.md", root / "static" / "CHANGELOG.md"):
|
|
if cl.is_file():
|
|
return cl.read_text(encoding="utf-8")
|
|
raise HTTPException(404, "CHANGELOG.md not found")
|
|
|
|
|
|
@router.get("/robots.txt", response_class=PlainTextResponse)
|
|
async def robots_txt():
|
|
return "User-agent: *\nDisallow: /"
|