(opus assessment) Fix auth wall flash, broken multi-user write API, and single-user redirect loop
Auth wall (Base.astro): set data-auth-pending on <body> at SSG time and hide
it with inline CSS before any JS runs; remove the attribute after /api/me
resolves. Eliminates the flash of protected content on private instances.
Multi-user write API (serve/server.py): the previous _apply_sidecar_edit and
strava_sync imports from bincio.edit.server were broken (those names don't
exist as module-level exports) and the Strava sync mutated a global data_dir,
making concurrent requests from different users racy. Fix: extract both
operations into bincio/edit/ops.py as pure functions that take data_dir
explicitly. Both edit/server.py and serve/server.py now import from there.
Security: add rate limiting to POST /api/register (5 attempts / 15 min / IP,
separate bucket from login). Add _check_id() activity ID validation to both
GET and POST /api/activity/{id} in serve/server.py.
Single-user mode: _write_root_manifest now forces instance.private=false when
no instance.db exists, even if a previous run wrote true. Prevents the auth
wall from firing and redirecting to /login/ when bincio serve isn't running.
ActivityFeed: skip filterHandle when profileIndexUrl is set (per-user profile
pages load the right shard directly; activities have no handle tag at that
point, so the filter was producing an empty feed). Fix handle links to point
to /u/{handle}/ instead of /{handle}/. Fix <a>-inside-<a> Svelte warning by
converting the inner handle link to a <button>.
This commit is contained in:
+7
-89
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -405,37 +404,10 @@ async def save_activity(activity_id: str, payload: dict[str, Any]) -> JSONRespon
|
|||||||
if not (dd / "activities" / f"{activity_id}.json").exists():
|
if not (dd / "activities" / f"{activity_id}.json").exists():
|
||||||
raise HTTPException(404, f"Activity {activity_id!r} not found")
|
raise HTTPException(404, f"Activity {activity_id!r} not found")
|
||||||
|
|
||||||
edits_dir = dd / "edits"
|
from bincio.edit.ops import apply_sidecar_edit
|
||||||
edits_dir.mkdir(exist_ok=True)
|
apply_sidecar_edit(activity_id, payload, dd)
|
||||||
sidecar_path = edits_dir / f"{activity_id}.md"
|
|
||||||
|
|
||||||
lines: list[str] = []
|
|
||||||
if payload.get("title"):
|
|
||||||
lines.append(f"title: {json.dumps(payload['title'])}")
|
|
||||||
if payload.get("sport") and payload["sport"] in SPORTS and payload["sport"] != "other":
|
|
||||||
lines.append(f"sport: {payload['sport']}")
|
|
||||||
if payload.get("gear"):
|
|
||||||
lines.append(f"gear: {json.dumps(payload['gear'])}")
|
|
||||||
if payload.get("highlight"):
|
|
||||||
lines.append("highlight: true")
|
|
||||||
if payload.get("private"):
|
|
||||||
lines.append("private: true")
|
|
||||||
hide = [s for s in (payload.get("hide_stats") or []) if s in STAT_PANELS]
|
|
||||||
if hide:
|
|
||||||
lines.append(f"hide_stats: [{', '.join(hide)}]")
|
|
||||||
|
|
||||||
description = (payload.get("description") or "").strip()
|
|
||||||
|
|
||||||
content = "---\n" + "\n".join(lines) + "\n---\n"
|
|
||||||
if description:
|
|
||||||
content += "\n" + description + "\n"
|
|
||||||
|
|
||||||
sidecar_path.write_text(content, encoding="utf-8")
|
|
||||||
|
|
||||||
# Re-merge so the Astro dev server immediately serves updated data
|
|
||||||
from bincio.render.merge import merge_all
|
|
||||||
merge_all(dd)
|
|
||||||
|
|
||||||
|
sidecar_path = dd / "edits" / f"{activity_id}.md"
|
||||||
return JSONResponse({"ok": True, "sidecar": str(sidecar_path)})
|
return JSONResponse({"ok": True, "sidecar": str(sidecar_path)})
|
||||||
|
|
||||||
|
|
||||||
@@ -726,66 +698,12 @@ async def strava_sync() -> JSONResponse:
|
|||||||
if not strava_client_id or not strava_client_secret:
|
if not strava_client_id or not strava_client_secret:
|
||||||
raise HTTPException(400, "Strava not configured. Pass --strava-client-id and --strava-client-secret to bincio edit.")
|
raise HTTPException(400, "Strava not configured. Pass --strava-client-id and --strava-client-secret to bincio edit.")
|
||||||
dd = _get_data_dir()
|
dd = _get_data_dir()
|
||||||
|
from bincio.edit.ops import run_strava_sync
|
||||||
from bincio.extract.strava_api import (
|
|
||||||
StravaError, ensure_fresh, fetch_activities, fetch_streams,
|
|
||||||
save_token, strava_to_parsed,
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
token = ensure_fresh(dd, strava_client_id, strava_client_secret)
|
result = run_strava_sync(dd, strava_client_id, strava_client_secret)
|
||||||
except StravaError as e:
|
except RuntimeError as e:
|
||||||
raise HTTPException(502, str(e))
|
raise HTTPException(502, str(e))
|
||||||
|
return JSONResponse(result)
|
||||||
after: int | None = token.get("last_sync_at")
|
|
||||||
try:
|
|
||||||
activities = fetch_activities(token["access_token"], after=after)
|
|
||||||
except StravaError as e:
|
|
||||||
raise HTTPException(502, str(e))
|
|
||||||
|
|
||||||
from bincio.extract.metrics import compute
|
|
||||||
from bincio.extract.writer import build_summary, make_activity_id, write_activity, write_index
|
|
||||||
from bincio.extract.strava_api import strava_meta_to_partial
|
|
||||||
from bincio.render.merge import merge_all
|
|
||||||
|
|
||||||
# Load existing index once
|
|
||||||
index_path = dd / "index.json"
|
|
||||||
if index_path.exists():
|
|
||||||
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
|
||||||
else:
|
|
||||||
index_data = {"owner": {"handle": "unknown"}, "activities": []}
|
|
||||||
owner = index_data.get("owner", {})
|
|
||||||
summaries: dict[str, dict] = {s["id"]: s for s in index_data.get("activities", [])}
|
|
||||||
|
|
||||||
imported = 0
|
|
||||||
skipped = 0
|
|
||||||
errors: list[str] = []
|
|
||||||
|
|
||||||
for meta in activities:
|
|
||||||
try:
|
|
||||||
# Compute ID from meta alone (no API call) to skip already-known activities
|
|
||||||
activity_id = make_activity_id(strava_meta_to_partial(meta))
|
|
||||||
if (dd / "activities" / f"{activity_id}.json").exists():
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Only fetch streams for genuinely new activities
|
|
||||||
streams = fetch_streams(token["access_token"], meta["id"])
|
|
||||||
parsed = strava_to_parsed(meta, streams)
|
|
||||||
metrics = compute(parsed)
|
|
||||||
write_activity(parsed, metrics, dd, privacy="public", rdp_epsilon=0.0001)
|
|
||||||
summaries[activity_id] = build_summary(parsed, metrics, activity_id, "public")
|
|
||||||
imported += 1
|
|
||||||
except Exception as exc:
|
|
||||||
errors.append(f"{meta.get('id')}: {type(exc).__name__}")
|
|
||||||
|
|
||||||
if imported:
|
|
||||||
write_index(list(summaries.values()), dd, owner)
|
|
||||||
merge_all(dd)
|
|
||||||
|
|
||||||
token["last_sync_at"] = int(time.time())
|
|
||||||
save_token(dd, token)
|
|
||||||
|
|
||||||
return JSONResponse({"ok": True, "imported": imported, "skipped": skipped, "error_count": len(errors), "errors": errors[:5]})
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/strava/reset")
|
@app.post("/api/strava/reset")
|
||||||
|
|||||||
@@ -107,9 +107,17 @@ def _write_root_manifest(data: Path) -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
has_auth = (data / "instance.db").exists()
|
||||||
|
existing_instance = existing.get("instance", {"name": "BincioActivity"})
|
||||||
|
if not has_auth:
|
||||||
|
# Single-user: no auth server, force private off regardless of what was written before.
|
||||||
|
existing_instance = {**existing_instance, "private": False}
|
||||||
|
elif "private" not in existing_instance:
|
||||||
|
# Multi-user first run: default to private.
|
||||||
|
existing_instance = {**existing_instance, "private": True}
|
||||||
manifest = {
|
manifest = {
|
||||||
"bas_version": "1.0",
|
"bas_version": "1.0",
|
||||||
"instance": existing.get("instance", {"name": "BincioActivity", "private": True}),
|
"instance": existing_instance,
|
||||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"shards": [
|
"shards": [
|
||||||
{
|
{
|
||||||
|
|||||||
+8
-1
@@ -16,7 +16,10 @@ console = Console()
|
|||||||
@click.option("--site-dir", default=None, type=click.Path(), help="Astro site dir for post-write rebuilds")
|
@click.option("--site-dir", default=None, type=click.Path(), help="Astro site dir for post-write rebuilds")
|
||||||
@click.option("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1 — proxy via nginx)")
|
@click.option("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1 — proxy via nginx)")
|
||||||
@click.option("--port", default=4041, help="Bind port (default: 4041)")
|
@click.option("--port", default=4041, help="Bind port (default: 4041)")
|
||||||
def serve(data_dir: str, site_dir: Optional[str], host: str, port: int) -> None:
|
@click.option("--strava-client-id", default=None, envvar="STRAVA_CLIENT_ID", help="Strava OAuth client ID (enables per-user Strava sync)")
|
||||||
|
@click.option("--strava-client-secret", default=None, envvar="STRAVA_CLIENT_SECRET", help="Strava OAuth client secret")
|
||||||
|
def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
|
||||||
|
strava_client_id: Optional[str], strava_client_secret: Optional[str]) -> None:
|
||||||
"""Start the bincio multi-user application server.
|
"""Start the bincio multi-user application server.
|
||||||
|
|
||||||
Handles auth, user management, and write operations.
|
Handles auth, user management, and write operations.
|
||||||
@@ -36,6 +39,10 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int) -> None:
|
|||||||
srv.data_dir = dd
|
srv.data_dir = dd
|
||||||
if site_dir:
|
if site_dir:
|
||||||
srv.site_dir = Path(site_dir).expanduser().resolve()
|
srv.site_dir = Path(site_dir).expanduser().resolve()
|
||||||
|
if strava_client_id:
|
||||||
|
srv.strava_client_id = strava_client_id
|
||||||
|
if strava_client_secret:
|
||||||
|
srv.strava_client_secret = strava_client_secret
|
||||||
|
|
||||||
console.print(f"[bold]bincio serve[/bold]")
|
console.print(f"[bold]bincio serve[/bold]")
|
||||||
console.print(f" Data: [cyan]{dd}[/cyan]")
|
console.print(f" Data: [cyan]{dd}[/cyan]")
|
||||||
|
|||||||
+42
-21
@@ -39,6 +39,8 @@ from bincio.serve.db import (
|
|||||||
|
|
||||||
data_dir: Path | None = None
|
data_dir: Path | None = None
|
||||||
site_dir: Path | None = None # for post-write rebuild trigger
|
site_dir: Path | None = None # for post-write rebuild trigger
|
||||||
|
strava_client_id: str = ""
|
||||||
|
strava_client_secret: str = ""
|
||||||
_db = None # sqlite3.Connection, opened lazily
|
_db = None # sqlite3.Connection, opened lazily
|
||||||
|
|
||||||
|
|
||||||
@@ -68,24 +70,38 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_VALID_HANDLE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,29}$')
|
_VALID_HANDLE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,29}$')
|
||||||
|
_VALID_ACTIVITY_ID = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9\-]{0,250}$')
|
||||||
_SESSION_COOKIE = "bincio_session"
|
_SESSION_COOKIE = "bincio_session"
|
||||||
_COOKIE_MAX_AGE = 30 * 86400 # 30 days
|
_COOKIE_MAX_AGE = 30 * 86400 # 30 days
|
||||||
|
|
||||||
|
|
||||||
|
def _check_id(activity_id: str) -> str:
|
||||||
|
if not _VALID_ACTIVITY_ID.match(activity_id):
|
||||||
|
raise HTTPException(400, "Invalid activity ID")
|
||||||
|
return activity_id
|
||||||
|
|
||||||
# ── Rate limiting (simple in-memory, per IP) ──────────────────────────────────
|
# ── Rate limiting (simple in-memory, per IP) ──────────────────────────────────
|
||||||
|
|
||||||
_login_attempts: dict[str, list[float]] = {}
|
_login_attempts: dict[str, list[float]] = {}
|
||||||
|
_register_attempts: dict[str, list[float]] = {}
|
||||||
_RATE_WINDOW = 900 # 15 minutes
|
_RATE_WINDOW = 900 # 15 minutes
|
||||||
_RATE_LIMIT = 10
|
_LOGIN_RATE_LIMIT = 10
|
||||||
|
_REGISTER_RATE_LIMIT = 5
|
||||||
|
|
||||||
|
|
||||||
def _check_rate_limit(ip: str) -> None:
|
def _check_rate_limit(
|
||||||
|
ip: str,
|
||||||
|
store: dict[str, list[float]],
|
||||||
|
limit: int,
|
||||||
|
msg: str = "Too many attempts. Try again later.",
|
||||||
|
) -> None:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
attempts = [t for t in _login_attempts.get(ip, []) if now - t < _RATE_WINDOW]
|
attempts = [t for t in store.get(ip, []) if now - t < _RATE_WINDOW]
|
||||||
_login_attempts[ip] = attempts
|
store[ip] = attempts
|
||||||
if len(attempts) >= _RATE_LIMIT:
|
if len(attempts) >= limit:
|
||||||
raise HTTPException(429, "Too many login attempts. Try again later.")
|
raise HTTPException(429, msg)
|
||||||
attempts.append(now)
|
attempts.append(now)
|
||||||
_login_attempts[ip] = attempts
|
store[ip] = attempts
|
||||||
|
|
||||||
|
|
||||||
# ── Auth helpers ──────────────────────────────────────────────────────────────
|
# ── Auth helpers ──────────────────────────────────────────────────────────────
|
||||||
@@ -154,7 +170,7 @@ async def me(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRespon
|
|||||||
@app.post("/api/auth/login")
|
@app.post("/api/auth/login")
|
||||||
async def login(request: Request) -> JSONResponse:
|
async def login(request: Request) -> JSONResponse:
|
||||||
ip = request.client.host if request.client else "unknown"
|
ip = request.client.host if request.client else "unknown"
|
||||||
_check_rate_limit(ip)
|
_check_rate_limit(ip, _login_attempts, _LOGIN_RATE_LIMIT, "Too many login attempts. Try again later.")
|
||||||
|
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
handle = body.get("handle", "").strip().lower()
|
handle = body.get("handle", "").strip().lower()
|
||||||
@@ -183,6 +199,9 @@ async def logout(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRe
|
|||||||
|
|
||||||
@app.post("/api/register")
|
@app.post("/api/register")
|
||||||
async def register(request: Request) -> JSONResponse:
|
async def register(request: Request) -> JSONResponse:
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
_check_rate_limit(ip, _register_attempts, _REGISTER_RATE_LIMIT, "Too many registration attempts. Try again later.")
|
||||||
|
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
code = body.get("code", "").strip().upper()
|
code = body.get("code", "").strip().upper()
|
||||||
handle = body.get("handle", "").strip().lower()
|
handle = body.get("handle", "").strip().lower()
|
||||||
@@ -276,6 +295,7 @@ async def get_activity(
|
|||||||
bincio_session: Optional[str] = Cookie(default=None),
|
bincio_session: Optional[str] = Cookie(default=None),
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
user = _require_user(bincio_session)
|
user = _require_user(bincio_session)
|
||||||
|
_check_id(activity_id)
|
||||||
path = _require_owns(activity_id, user)
|
path = _require_owns(activity_id, user)
|
||||||
return JSONResponse(json.loads(path.read_text()))
|
return JSONResponse(json.loads(path.read_text()))
|
||||||
|
|
||||||
@@ -287,28 +307,29 @@ async def post_activity(
|
|||||||
bincio_session: Optional[str] = Cookie(default=None),
|
bincio_session: Optional[str] = Cookie(default=None),
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
user = _require_user(bincio_session)
|
user = _require_user(bincio_session)
|
||||||
|
_check_id(activity_id)
|
||||||
dd = _get_data_dir() / user.handle
|
dd = _get_data_dir() / user.handle
|
||||||
|
# Verify the activity belongs to this user before writing
|
||||||
|
if not (dd / "activities" / f"{activity_id}.json").exists():
|
||||||
|
raise HTTPException(404, "Activity not found")
|
||||||
|
|
||||||
from bincio.edit.server import _apply_sidecar_edit # type: ignore[attr-defined]
|
from bincio.edit.ops import apply_sidecar_edit
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
_apply_sidecar_edit(activity_id, body, dd)
|
apply_sidecar_edit(activity_id, body, dd)
|
||||||
_trigger_rebuild(user.handle)
|
_trigger_rebuild(user.handle)
|
||||||
return JSONResponse({"ok": True})
|
return JSONResponse({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/strava/sync")
|
@app.post("/api/strava/sync")
|
||||||
async def strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||||
user = _require_user(bincio_session)
|
user = _require_user(bincio_session)
|
||||||
|
if not strava_client_id or not strava_client_secret:
|
||||||
|
raise HTTPException(400, "Strava not configured on this server")
|
||||||
dd = _get_data_dir() / user.handle
|
dd = _get_data_dir() / user.handle
|
||||||
# Delegate to edit server logic but using user's data dir
|
from bincio.edit.ops import run_strava_sync
|
||||||
from bincio.edit.server import strava_sync as _sync # type: ignore[attr-defined]
|
|
||||||
# Temporarily override the global data_dir used by edit server
|
|
||||||
import bincio.edit.server as edit_srv
|
|
||||||
old = edit_srv.data_dir
|
|
||||||
edit_srv.data_dir = dd
|
|
||||||
try:
|
try:
|
||||||
result = await _sync()
|
result = run_strava_sync(dd, strava_client_id, strava_client_secret)
|
||||||
finally:
|
except RuntimeError as e:
|
||||||
edit_srv.data_dir = old
|
raise HTTPException(502, str(e))
|
||||||
_trigger_rebuild(user.handle)
|
_trigger_rebuild(user.handle)
|
||||||
return result
|
return JSONResponse(result)
|
||||||
|
|||||||
@@ -66,7 +66,13 @@
|
|||||||
: `${base}data/index.json`;
|
: `${base}data/index.json`;
|
||||||
const index = await loadIndex(base, indexUrl);
|
const index = await loadIndex(base, indexUrl);
|
||||||
let activities = index.activities.filter(a => a.privacy !== 'private');
|
let activities = index.activities.filter(a => a.privacy !== 'private');
|
||||||
if (filterHandle) activities = activities.filter(a => a.handle === filterHandle);
|
// filterHandle only applies when loading the root manifest (multi-user feed).
|
||||||
|
// When profileIndexUrl is set we already loaded the right user's shard directly —
|
||||||
|
// activities from a direct shard fetch have no handle tag, so the filter would
|
||||||
|
// remove everything.
|
||||||
|
if (filterHandle && !profileIndexUrl) {
|
||||||
|
activities = activities.filter(a => a.handle === filterHandle);
|
||||||
|
}
|
||||||
all = activities;
|
all = activities;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error = e.message;
|
error = e.message;
|
||||||
@@ -130,7 +136,7 @@
|
|||||||
<div class="flex items-start justify-between gap-2 mb-3">
|
<div class="flex items-start justify-between gap-2 mb-3">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p class="text-xs text-zinc-500 mb-0.5">
|
<p class="text-xs text-zinc-500 mb-0.5">
|
||||||
{formatDate(a.started_at)}{#if a.handle} · <a href={`${import.meta.env.BASE_URL}${a.handle}/`} class="hover:text-zinc-300 transition-colors" on:click|stopPropagation>@{a.handle}</a>{/if}
|
{formatDate(a.started_at)}{#if a.handle} · <button class="hover:text-zinc-300 transition-colors" on:click|stopPropagation={() => window.location.href = `${import.meta.env.BASE_URL}u/${a.handle}/`}>@{a.handle}</button>{/if}
|
||||||
</p>
|
</p>
|
||||||
<h3 class="font-semibold text-white truncate group-hover:text-[--accent] transition-colors">
|
<h3 class="font-semibold text-white truncate group-hover:text-[--accent] transition-colors">
|
||||||
{a.title}
|
{a.title}
|
||||||
|
|||||||
@@ -52,12 +52,21 @@ try {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Auth wall: redirect to /login/ on private instances when not authenticated -->
|
<!-- Auth wall: redirect to /login/ on private instances when not authenticated.
|
||||||
|
body[data-auth-pending] is hidden by CSS (below) before the check resolves,
|
||||||
|
eliminating the flash of protected content. -->
|
||||||
{instancePrivate && !isPublicPage && (
|
{instancePrivate && !isPublicPage && (
|
||||||
|
<style is:inline>[data-auth-pending]{visibility:hidden}</style>
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
fetch('/api/me', { credentials: 'include' })
|
fetch('/api/me', { credentials: 'include' })
|
||||||
.then(r => { if (r.status === 401 || r.status === 404) window.location.replace('/login/'); })
|
.then(r => {
|
||||||
.catch(() => {});
|
if (r.status === 401 || r.status === 404) {
|
||||||
|
window.location.replace('/login/');
|
||||||
|
} else {
|
||||||
|
document.body.removeAttribute('data-auth-pending');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { document.body.removeAttribute('data-auth-pending'); });
|
||||||
</script>
|
</script>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -132,6 +141,7 @@ try {
|
|||||||
<body
|
<body
|
||||||
class="font-sans antialiased min-h-screen"
|
class="font-sans antialiased min-h-screen"
|
||||||
style="background-color: var(--bg-base); color: var(--text-primary)"
|
style="background-color: var(--bg-base); color: var(--text-primary)"
|
||||||
|
data-auth-pending={instancePrivate && !isPublicPage ? '' : undefined}
|
||||||
>
|
>
|
||||||
<nav
|
<nav
|
||||||
class="border-b border-zinc-800 sticky top-0 z-50 bg-zinc-950/90 backdrop-blur"
|
class="border-b border-zinc-800 sticky top-0 z-50 bg-zinc-950/90 backdrop-blur"
|
||||||
|
|||||||
Reference in New Issue
Block a user