diff --git a/bincio/serve/cli.py b/bincio/serve/cli.py index ae07a44..9825cc0 100644 --- a/bincio/serve/cli.py +++ b/bincio/serve/cli.py @@ -20,9 +20,11 @@ console = Console() @click.option("--strava-client-secret", default=None, envvar="STRAVA_CLIENT_SECRET", help="Strava OAuth client secret") @click.option("--max-users", default=None, type=int, help="Override max users for this instance (0 = unlimited; updates the DB setting)") @click.option("--public-url", default=None, envvar="PUBLIC_URL", help="Public base URL (e.g. https://yourdomain.com). Required for Strava OAuth to work behind a reverse proxy.") +@click.option("--webroot", default=None, type=click.Path(), help="Nginx webroot (e.g. /var/www/bincio). When set, uploads trigger a full Astro build + rsync so new activity pages are immediately accessible without a git push.") def serve(data_dir: str, site_dir: Optional[str], host: str, port: int, strava_client_id: Optional[str], strava_client_secret: Optional[str], - max_users: Optional[int], public_url: Optional[str]) -> None: + max_users: Optional[int], public_url: Optional[str], + webroot: Optional[str]) -> None: """Start the bincio multi-user application server. Handles auth, user management, and write operations. @@ -54,6 +56,8 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int, srv.strava_client_secret = strava_client_secret if public_url: srv.public_url = public_url + if webroot and site_dir: + srv.webroot = Path(webroot).expanduser().resolve() db = open_db(dd) current_limit = get_setting(db, "max_users") @@ -63,6 +67,8 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int, console.print(f" Data: [cyan]{dd}[/cyan]") if srv.site_dir: console.print(f" Site: [cyan]{srv.site_dir}[/cyan]") + if srv.webroot: + console.print(f" Web: [cyan]{srv.webroot}[/cyan] (auto-rebuild on upload)") console.print(f" URL: [cyan]http://{host}:{port}[/cyan]") if current_limit and int(current_limit) > 0: console.print(f" Users: [yellow]max {current_limit}[/yellow]") diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 52057cf..7126b9c 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -82,6 +82,7 @@ def _job_finish(job_id: str) -> None: data_dir: Path | None = None site_dir: Path | None = None # for post-write rebuild trigger +webroot: Path | None = None # nginx webroot — when set, trigger full rebuild + rsync strava_client_id: str = "" strava_client_secret: str = "" public_url: str = "" # e.g. "https://yourdomain.com" — used for OAuth redirect URIs @@ -201,25 +202,67 @@ def _unique_image_name(directory: Path, filename: str) -> str: # ── Post-write rebuild ──────────────────────────────────────────────────────── +# Serialises concurrent rebuilds — only one full build runs at a time. +# A second upload that arrives while a build is in progress will queue and +# run after the first finishes, picking up all data written in between. +_rebuild_lock = threading.Lock() + + def _trigger_rebuild(handle: str) -> None: - """Asynchronously re-merge one user's shard and rewrite the root manifest.""" + """Asynchronously re-merge and optionally rebuild + rsync the site. + + - Without --webroot: fast path — merges sidecars + rewrites root manifest + (~1 s). New activity pages require the nginx try_files fallback to work. + - With --webroot: full Astro build + rsync to the nginx webroot (~30–60 s, + serialised). New activity pages are immediately accessible. + """ if site_dir is None: return if not _VALID_HANDLE.match(handle): return # safety: never pass untrusted strings to subprocess + uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv") - try: - subprocess.Popen( - [uv, "run", "bincio", "render", - "--data-dir", str(data_dir), - "--site-dir", str(site_dir), - "--handle", handle, - "--no-build"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except Exception: - pass # rebuild failure must never 500 the calling endpoint + _data_dir = str(data_dir) + _site_dir = str(site_dir) + _webroot = str(webroot) if webroot else None + _handle = handle + + def _run() -> None: + try: + if _webroot is None: + # Fast: only update data, skip Astro build + subprocess.run( + [uv, "run", "bincio", "render", + "--data-dir", _data_dir, + "--site-dir", _site_dir, + "--handle", _handle, + "--no-build"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + else: + # Full build + rsync — serialised so concurrent uploads don't race + with _rebuild_lock: + result = subprocess.run( + [uv, "run", "bincio", "render", + "--data-dir", _data_dir, + "--site-dir", _site_dir, + "--handle", _handle], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if result.returncode == 0: + # Rsync built site to nginx webroot + subprocess.run( + ["rsync", "-a", "--delete", + f"{_site_dir}/dist/", _webroot + "/"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception: + pass # rebuild failure must never affect the calling request + + threading.Thread(target=_run, daemon=True).start() # ── Auth endpoints ──────────────────────────────────────────────────────────── diff --git a/docs/deployment/vps.md b/docs/deployment/vps.md index 8842d08..21a9538 100644 --- a/docs/deployment/vps.md +++ b/docs/deployment/vps.md @@ -113,6 +113,7 @@ WorkingDirectory=/opt/bincio ExecStart=/root/.local/bin/uv run bincio serve \ --data-dir /var/bincio/data \ --site-dir /opt/bincio/site \ + --webroot /var/www/bincio \ --host 127.0.0.1 \ --port 4041 \ --public-url https://yourdomain.com