diff --git a/bincio/cli.py b/bincio/cli.py index 0360bce..cb877a9 100644 --- a/bincio/cli.py +++ b/bincio/cli.py @@ -15,8 +15,12 @@ from bincio.extract.cli import extract # noqa: E402 from bincio.render.cli import render # noqa: E402 from bincio.edit.cli import edit # noqa: E402 from bincio.import_.cli import import_group # noqa: E402 +from bincio.serve.init_cmd import init # noqa: E402 +from bincio.serve.cli import serve # noqa: E402 main.add_command(extract) main.add_command(render) main.add_command(edit) main.add_command(import_group) +main.add_command(init) +main.add_command(serve) diff --git a/bincio/render/cli.py b/bincio/render/cli.py index f8dbf89..517ec5f 100644 --- a/bincio/render/cli.py +++ b/bincio/render/cli.py @@ -70,20 +70,81 @@ def _ensure_npm(site: Path) -> None: subprocess.run(["npm", "install"], cwd=site, check=True) -def _merge_edits(data: Path) -> None: - """Run the sidecar merge step, producing data/_merged/.""" +def _is_multiuser(data: Path) -> bool: + return (data / "instance.db").exists() + + +def _user_dirs(data: Path) -> list[Path]: + """Return all per-user subdirectories (contain an activities/ dir).""" + return sorted( + p for p in data.iterdir() + if p.is_dir() and (p / "activities").exists() + ) + + +def _merge_edits(data: Path, handle: str | None = None) -> None: + """Run the sidecar merge step for one user or all users.""" from bincio.render.merge import merge_all - n = merge_all(data) - if n: - console.print(f"Merged [cyan]{n}[/cyan] sidecar edit(s) into _merged/") + + if _is_multiuser(data): + targets = [data / handle] if handle else _user_dirs(data) + total = 0 + for user_dir in targets: + n = merge_all(user_dir) + total += n + console.print(f" [cyan]{user_dir.name}[/cyan]: {n} sidecar(s) merged") + if not total: + console.print("No sidecars found — _merged/ dirs mirror extracted data.") else: - console.print("No sidecars found — _merged/ mirrors extracted data.") + n = merge_all(data) + if n: + console.print(f"Merged [cyan]{n}[/cyan] sidecar edit(s) into _merged/") + else: + console.print("No sidecars found — _merged/ mirrors extracted data.") + + +def _write_root_manifest(data: Path) -> None: + """Rewrite the root index.json shard manifest from current user dirs.""" + import json + from datetime import datetime, timezone + + users = _user_dirs(data) + # Read existing manifest to preserve instance metadata + root = data / "index.json" + existing: dict = {} + if root.exists(): + try: + existing = json.loads(root.read_text()) + except Exception: + pass + + manifest = { + "bas_version": "1.0", + "instance": existing.get("instance", {"name": "BincioActivity", "private": True}), + "generated_at": datetime.now(timezone.utc).isoformat(), + "shards": [ + { + "handle": u.name, + "url": f"{u.name}/_merged/index.json" + if (u / "_merged" / "index.json").exists() + else f"{u.name}/index.json", + } + for u in users + ], + "activities": [], + } + root.write_text(json.dumps(manifest, indent=2)) + console.print(f"Root manifest updated: [cyan]{len(users)}[/cyan] user shard(s)") def _link_data(site: Path, data: Path) -> None: - """Symlink site/public/data → data/_merged/ (the post-merge output).""" - merged = data / "_merged" - target = merged if merged.exists() else data + """Symlink site/public/data → data (multi-user) or data/_merged/ (single-user).""" + if _is_multiuser(data): + # Multi-user: link to data root directly (each user has their own _merged/) + target = data + else: + merged = data / "_merged" + target = merged if merged.exists() else data public_data = site / "public" / "data" public_data.parent.mkdir(parents=True, exist_ok=True) if public_data.is_symlink(): @@ -113,6 +174,8 @@ def _link_data(site: Path, data: Path) -> None: help="Start dev server with hot reload instead of building.") @click.option("--deploy", default=None, metavar="TARGET", help="Deploy after build. Currently supports: github.") +@click.option("--handle", default=None, + help="(Multi-user) Incrementally re-merge one user's shard only.") def render( config_path: Optional[str], data_dir: Optional[str], @@ -120,6 +183,7 @@ def render( out_dir: Optional[str], serve: bool, deploy: Optional[str], + handle: Optional[str], ) -> None: """Build (or serve) the BincioActivity static site from a BAS data store.""" @@ -129,8 +193,14 @@ def render( console.print(f"Site: [cyan]{site}[/cyan]") console.print(f"Data: [cyan]{data}[/cyan]") + multiuser = _is_multiuser(data) + if multiuser: + console.print("[cyan]Multi-user mode[/cyan]") + _ensure_npm(site) - _merge_edits(data) + _merge_edits(data, handle=handle) + if multiuser: + _write_root_manifest(data) _link_data(site, data) env = {**os.environ, "BINCIO_DATA_DIR": str(data)} diff --git a/bincio/serve/__init__.py b/bincio/serve/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bincio/serve/cli.py b/bincio/serve/cli.py new file mode 100644 index 0000000..4abfb54 --- /dev/null +++ b/bincio/serve/cli.py @@ -0,0 +1,47 @@ +"""bincio serve — CLI entry point for the multi-user VPS server.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +import click +from rich.console import Console + +console = Console() + + +@click.command("serve") +@click.option("--data-dir", required=True, type=click.Path(), help="BAS data directory (contains instance.db)") +@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("--port", default=4041, help="Bind port (default: 4041)") +def serve(data_dir: str, site_dir: Optional[str], host: str, port: int) -> None: + """Start the bincio multi-user application server. + + Handles auth, user management, and write operations. + Intended to run behind nginx which serves static files. + + Requires a data directory initialised with `bincio init`. + """ + import uvicorn + import bincio.serve.server as srv + + dd = Path(data_dir).expanduser().resolve() + if not (dd / "instance.db").exists(): + raise click.UsageError( + f"No instance.db found in {dd}. Run `bincio init --data-dir {dd}` first." + ) + + srv.data_dir = dd + if site_dir: + srv.site_dir = Path(site_dir).expanduser().resolve() + + console.print(f"[bold]bincio serve[/bold]") + console.print(f" Data: [cyan]{dd}[/cyan]") + if srv.site_dir: + console.print(f" Site: [cyan]{srv.site_dir}[/cyan]") + console.print(f" URL: [cyan]http://{host}:{port}[/cyan]") + console.print() + + uvicorn.run(srv.app, host=host, port=port, log_level="info") diff --git a/bincio/serve/db.py b/bincio/serve/db.py new file mode 100644 index 0000000..16d3b7f --- /dev/null +++ b/bincio/serve/db.py @@ -0,0 +1,265 @@ +"""SQLite data layer for bincio multi-user instances. + +Schema +------ +users — registered accounts (handle, hashed password, admin flag) +sessions — active login sessions (token → handle, expiry) +invites — invite codes (who created, who used, when) + +All timestamps are Unix integers (UTC). +Passwords are hashed with bcrypt. +""" + +from __future__ import annotations + +import secrets +import sqlite3 +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +import bcrypt + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + handle TEXT PRIMARY KEY, + display_name TEXT NOT NULL DEFAULT '', + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + handle TEXT NOT NULL REFERENCES users(handle) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS invites ( + code TEXT PRIMARY KEY, + created_by TEXT NOT NULL REFERENCES users(handle) ON DELETE CASCADE, + used_by TEXT REFERENCES users(handle) ON DELETE SET NULL, + created_at INTEGER NOT NULL, + used_at INTEGER +); + +CREATE INDEX IF NOT EXISTS sessions_handle ON sessions(handle); +CREATE INDEX IF NOT EXISTS invites_created_by ON invites(created_by); +""" + +_SESSION_DAYS = 30 +_INVITE_LENGTH = 8 + + +# ── Data classes ────────────────────────────────────────────────────────────── + +@dataclass +class User: + handle: str + display_name: str + is_admin: bool + created_at: int + + +@dataclass +class Invite: + code: str + created_by: str + used_by: Optional[str] + created_at: int + used_at: Optional[int] + + @property + def used(self) -> bool: + return self.used_by is not None + + +# ── Connection ──────────────────────────────────────────────────────────────── + +def open_db(data_dir: Path) -> sqlite3.Connection: + """Open (and if needed create) the instance database.""" + db = sqlite3.connect(data_dir / "instance.db", check_same_thread=False) + db.row_factory = sqlite3.Row + db.execute("PRAGMA journal_mode=WAL") + db.execute("PRAGMA foreign_keys=ON") + db.executescript(_SCHEMA) + db.commit() + return db + + +# ── Users ───────────────────────────────────────────────────────────────────── + +def create_user( + db: sqlite3.Connection, + handle: str, + display_name: str, + password: str, + is_admin: bool = False, +) -> User: + password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + now = int(time.time()) + db.execute( + "INSERT INTO users (handle, display_name, password_hash, is_admin, created_at) " + "VALUES (?, ?, ?, ?, ?)", + (handle, display_name, password_hash, int(is_admin), now), + ) + db.commit() + return User(handle=handle, display_name=display_name, is_admin=is_admin, created_at=now) + + +def get_user(db: sqlite3.Connection, handle: str) -> Optional[User]: + row = db.execute("SELECT * FROM users WHERE handle = ?", (handle,)).fetchone() + if not row: + return None + return User( + handle=row["handle"], + display_name=row["display_name"], + is_admin=bool(row["is_admin"]), + created_at=row["created_at"], + ) + + +def authenticate(db: sqlite3.Connection, handle: str, password: str) -> Optional[User]: + """Return the User if credentials are valid, else None.""" + row = db.execute( + "SELECT * FROM users WHERE handle = ?", (handle,) + ).fetchone() + if not row: + return None + if not bcrypt.checkpw(password.encode(), row["password_hash"].encode()): + return None + return User( + handle=row["handle"], + display_name=row["display_name"], + is_admin=bool(row["is_admin"]), + created_at=row["created_at"], + ) + + +def list_users(db: sqlite3.Connection) -> list[User]: + rows = db.execute("SELECT * FROM users ORDER BY created_at").fetchall() + return [User(handle=r["handle"], display_name=r["display_name"], + is_admin=bool(r["is_admin"]), created_at=r["created_at"]) for r in rows] + + +def delete_user(db: sqlite3.Connection, handle: str) -> None: + db.execute("DELETE FROM users WHERE handle = ?", (handle,)) + db.commit() + + +# ── Sessions ────────────────────────────────────────────────────────────────── + +def create_session(db: sqlite3.Connection, handle: str) -> str: + """Create a session token for the given user. Returns the token.""" + token = secrets.token_hex(32) + now = int(time.time()) + expires_at = now + _SESSION_DAYS * 86400 + db.execute( + "INSERT INTO sessions (token, handle, created_at, expires_at) VALUES (?, ?, ?, ?)", + (token, handle, now, expires_at), + ) + db.commit() + return token + + +def get_session(db: sqlite3.Connection, token: str) -> Optional[User]: + """Return the User owning this session, or None if expired/invalid.""" + row = db.execute( + "SELECT s.handle, s.expires_at, u.display_name, u.is_admin, u.created_at " + "FROM sessions s JOIN users u ON s.handle = u.handle " + "WHERE s.token = ?", + (token,), + ).fetchone() + if not row: + return None + if row["expires_at"] < int(time.time()): + delete_session(db, token) + return None + return User( + handle=row["handle"], + display_name=row["display_name"], + is_admin=bool(row["is_admin"]), + created_at=row["created_at"], + ) + + +def delete_session(db: sqlite3.Connection, token: str) -> None: + db.execute("DELETE FROM sessions WHERE token = ?", (token,)) + db.commit() + + +def purge_expired_sessions(db: sqlite3.Connection) -> int: + cur = db.execute("DELETE FROM sessions WHERE expires_at < ?", (int(time.time()),)) + db.commit() + return cur.rowcount + + +# ── Invites ─────────────────────────────────────────────────────────────────── + +_MAX_USER_INVITES = 3 # regular users; admins are unlimited + + +def create_invite(db: sqlite3.Connection, created_by: str) -> str: + """Generate an invite code. Raises ValueError if the user has hit their limit.""" + user = get_user(db, created_by) + if user and not user.is_admin: + count = db.execute( + "SELECT COUNT(*) FROM invites WHERE created_by = ?", (created_by,) + ).fetchone()[0] + if count >= _MAX_USER_INVITES: + raise ValueError(f"Invite limit reached ({_MAX_USER_INVITES})") + + code = secrets.token_urlsafe(_INVITE_LENGTH)[:_INVITE_LENGTH].upper() + db.execute( + "INSERT INTO invites (code, created_by, created_at) VALUES (?, ?, ?)", + (code, created_by, int(time.time())), + ) + db.commit() + return code + + +def use_invite(db: sqlite3.Connection, code: str, handle: str) -> bool: + """Mark an invite as used. Returns False if the code is invalid or already used.""" + row = db.execute( + "SELECT used_by FROM invites WHERE code = ?", (code,) + ).fetchone() + if not row or row["used_by"] is not None: + return False + db.execute( + "UPDATE invites SET used_by = ?, used_at = ? WHERE code = ?", + (handle, int(time.time()), code), + ) + db.commit() + return True + + +def list_invites(db: sqlite3.Connection, handle: str) -> list[Invite]: + rows = db.execute( + "SELECT * FROM invites WHERE created_by = ? ORDER BY created_at DESC", + (handle,), + ).fetchall() + return [ + Invite( + code=r["code"], + created_by=r["created_by"], + used_by=r["used_by"], + created_at=r["created_at"], + used_at=r["used_at"], + ) + for r in rows + ] + + +def get_invite(db: sqlite3.Connection, code: str) -> Optional[Invite]: + row = db.execute("SELECT * FROM invites WHERE code = ?", (code,)).fetchone() + if not row: + return None + return Invite( + code=row["code"], + created_by=row["created_by"], + used_by=row["used_by"], + created_at=row["created_at"], + used_at=row["used_at"], + ) diff --git a/bincio/serve/init_cmd.py b/bincio/serve/init_cmd.py new file mode 100644 index 0000000..3f5365a --- /dev/null +++ b/bincio/serve/init_cmd.py @@ -0,0 +1,84 @@ +"""bincio init — bootstrap a fresh multi-user instance.""" + +from __future__ import annotations + +from pathlib import Path + +import click +from rich.console import Console +from rich.panel import Panel + +console = Console() + + +@click.command("init") +@click.option("--data-dir", required=True, type=click.Path(), help="BAS data directory to initialise") +@click.option("--handle", required=True, help="Admin user handle (e.g. 'dave')") +@click.option("--password", required=True, hide_input=True, confirmation_prompt=True, help="Admin password") +@click.option("--display-name", default="", help="Admin display name (defaults to handle)") +@click.option("--name", default="", help="Instance name shown in the feed") +def init(data_dir: str, handle: str, password: str, display_name: str, name: str) -> None: + """Bootstrap a fresh bincio multi-user instance. + + Creates the SQLite database, the admin user, the per-user data directory, + and prints a first invite code. Safe to re-run — skips steps already done. + """ + from bincio.serve.db import create_invite, create_user, get_user, open_db + + dd = Path(data_dir).expanduser().resolve() + dd.mkdir(parents=True, exist_ok=True) + + console.print(f"[bold]Initialising bincio instance[/bold] at [cyan]{dd}[/cyan]") + + # ── Database ───────────────────────────────────────────────────────────── + db = open_db(dd) + console.print(" [green]✓[/green] instance.db ready") + + # ── Admin user ─────────────────────────────────────────────────────────── + existing = get_user(db, handle) + if existing: + console.print(f" [yellow]·[/yellow] user '{handle}' already exists — skipping") + else: + create_user(db, handle, display_name or handle, password, is_admin=True) + console.print(f" [green]✓[/green] admin user '{handle}' created") + + # ── User data directory ─────────────────────────────────────────────────── + user_dir = dd / handle + user_dir.mkdir(exist_ok=True) + (user_dir / "activities").mkdir(exist_ok=True) + (user_dir / "edits").mkdir(exist_ok=True) + console.print(f" [green]✓[/green] data dir {dd / handle}/ ready") + + # ── Root index.json shard manifest ─────────────────────────────────────── + import json + from datetime import datetime, timezone + + root_index = dd / "index.json" + if not root_index.exists(): + manifest = { + "bas_version": "1.0", + "instance": {"name": name or "BincioActivity", "private": True}, + "generated_at": datetime.now(timezone.utc).isoformat(), + "shards": [{"handle": handle, "url": f"{handle}/index.json"}], + "activities": [], + } + root_index.write_text(json.dumps(manifest, indent=2)) + console.print(" [green]✓[/green] root index.json manifest written") + else: + console.print(" [yellow]·[/yellow] root index.json already exists — skipping") + + # ── First invite code ───────────────────────────────────────────────────── + code = create_invite(db, handle) + + console.print() + console.print(Panel( + f"[bold green]Instance ready![/bold green]\n\n" + f"Admin: [cyan]{handle}[/cyan]\n" + f"Data dir: [cyan]{dd}[/cyan]\n\n" + f"First invite code:\n\n" + f" [bold yellow]{code}[/bold yellow]\n\n" + f"Share this link with your first user:\n" + f" /register/?code={code}", + title="bincio init", + border_style="green", + )) diff --git a/bincio/serve/server.py b/bincio/serve/server.py new file mode 100644 index 0000000..5e689ca --- /dev/null +++ b/bincio/serve/server.py @@ -0,0 +1,311 @@ +"""bincio serve — multi-user FastAPI application server. + +Handles auth, user management, and auth-gated write operations. +nginx serves static files; this server only handles /api/* routes. + +Run via `bincio serve` CLI command. +""" + +from __future__ import annotations + +import json +import re +import subprocess +import time +from pathlib import Path +from typing import Any, Optional + +from fastapi import Cookie, FastAPI, HTTPException, Request, Response +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from bincio.serve.db import ( + User, + authenticate, + create_invite, + create_session, + create_user, + delete_session, + get_invite, + get_session, + get_user, + list_invites, + list_users, + open_db, + use_invite, +) + +# ── Globals (set by CLI before uvicorn starts) ──────────────────────────────── + +data_dir: Path | None = None +site_dir: Path | None = None # for post-write rebuild trigger +_db = None # sqlite3.Connection, opened lazily + + +def _get_db(): + global _db + if _db is None: + _db = open_db(_get_data_dir()) + return _db + + +def _get_data_dir() -> Path: + if data_dir is None: + raise HTTPException(500, "Server not configured") + return data_dir + + +# ── App ─────────────────────────────────────────────────────────────────────── + +app = FastAPI(title="BincioActivity Serve", docs_url=None, redoc_url=None) + +app.add_middleware( + CORSMiddleware, + allow_origin_regex=r"https?://localhost(:\d+)?", + allow_credentials=True, + allow_methods=["GET", "POST", "DELETE"], + allow_headers=["Content-Type"], +) + +_VALID_HANDLE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,29}$') +_SESSION_COOKIE = "bincio_session" +_COOKIE_MAX_AGE = 30 * 86400 # 30 days + +# ── Rate limiting (simple in-memory, per IP) ────────────────────────────────── + +_login_attempts: dict[str, list[float]] = {} +_RATE_WINDOW = 900 # 15 minutes +_RATE_LIMIT = 10 + + +def _check_rate_limit(ip: str) -> None: + now = time.time() + attempts = [t for t in _login_attempts.get(ip, []) if now - t < _RATE_WINDOW] + _login_attempts[ip] = attempts + if len(attempts) >= _RATE_LIMIT: + raise HTTPException(429, "Too many login attempts. Try again later.") + attempts.append(now) + _login_attempts[ip] = attempts + + +# ── Auth helpers ────────────────────────────────────────────────────────────── + +def _current_user(bincio_session: Optional[str] = Cookie(default=None)) -> Optional[User]: + if not bincio_session: + return None + return get_session(_get_db(), bincio_session) + + +def _require_user(bincio_session: Optional[str] = Cookie(default=None)) -> User: + user = _current_user(bincio_session) + if not user: + raise HTTPException(401, "Not authenticated") + return user + + +def _require_admin(bincio_session: Optional[str] = Cookie(default=None)) -> User: + user = _require_user(bincio_session) + if not user.is_admin: + raise HTTPException(403, "Admin required") + return user + + +def _set_session_cookie(response: Response, token: str) -> None: + response.set_cookie( + key=_SESSION_COOKIE, + value=token, + max_age=_COOKIE_MAX_AGE, + httponly=True, + samesite="lax", + secure=False, # nginx/caddy handles TLS termination + ) + + +# ── Post-write rebuild ──────────────────────────────────────────────────────── + +def _trigger_rebuild(handle: str) -> None: + """Asynchronously re-merge one user's shard and rewrite the root manifest.""" + if site_dir is None: + return + subprocess.Popen( + ["uv", "run", "bincio", "render", + "--data-dir", str(data_dir), + "--site-dir", str(site_dir), + "--handle", handle], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +# ── Auth endpoints ──────────────────────────────────────────────────────────── + +@app.get("/api/me") +async def me(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + user = _current_user(bincio_session) + if not user: + raise HTTPException(404, "Not authenticated") + return JSONResponse({ + "handle": user.handle, + "display_name": user.display_name, + "is_admin": user.is_admin, + }) + + +@app.post("/api/auth/login") +async def login(request: Request, response: Response) -> JSONResponse: + ip = request.client.host if request.client else "unknown" + _check_rate_limit(ip) + + body = await request.json() + handle = body.get("handle", "").strip().lower() + password = body.get("password", "") + + user = authenticate(_get_db(), handle, password) + if not user: + raise HTTPException(401, "Invalid credentials") + + token = create_session(_get_db(), handle) + _set_session_cookie(response, token) + return JSONResponse({"ok": True, "handle": user.handle, "display_name": user.display_name}) + + +@app.post("/api/auth/logout") +async def logout(response: Response, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + if bincio_session: + delete_session(_get_db(), bincio_session) + response.delete_cookie(_SESSION_COOKIE) + return JSONResponse({"ok": True}) + + +# ── Registration ────────────────────────────────────────────────────────────── + +@app.post("/api/register") +async def register(request: Request, response: Response) -> JSONResponse: + body = await request.json() + code = body.get("code", "").strip().upper() + handle = body.get("handle", "").strip().lower() + password = body.get("password", "") + display = body.get("display_name", "").strip() or handle + + if not _VALID_HANDLE.match(handle): + raise HTTPException(400, "Invalid handle (lowercase letters, numbers, _ - only; max 30 chars)") + if len(password) < 8: + raise HTTPException(400, "Password must be at least 8 characters") + + invite = get_invite(_get_db(), code) + if not invite or invite.used: + raise HTTPException(400, "Invalid or already-used invite code") + if get_user(_get_db(), handle): + raise HTTPException(409, "Handle already taken") + + create_user(_get_db(), handle, display, password, is_admin=False) + use_invite(_get_db(), code, handle) + + # Create per-user directories + dd = _get_data_dir() + (dd / handle / "activities").mkdir(parents=True, exist_ok=True) + (dd / handle / "edits").mkdir(parents=True, exist_ok=True) + + token = create_session(_get_db(), handle) + _set_session_cookie(response, token) + return JSONResponse({"ok": True, "handle": handle}) + + +# ── Invites ─────────────────────────────────────────────────────────────────── + +@app.get("/api/invites") +async def get_invites(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + user = _require_user(bincio_session) + invites = list_invites(_get_db(), user.handle) + return JSONResponse([{ + "code": i.code, + "used": i.used, + "used_by": i.used_by, + "created_at": i.created_at, + "used_at": i.used_at, + } for i in invites]) + + +@app.post("/api/invites") +async def post_invite(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + user = _require_user(bincio_session) + try: + code = create_invite(_get_db(), user.handle) + except ValueError as e: + raise HTTPException(400, str(e)) + return JSONResponse({"ok": True, "code": code}) + + +# ── Admin ───────────────────────────────────────────────────────────────────── + +@app.get("/api/admin/users") +async def admin_users(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + _require_admin(bincio_session) + users = list_users(_get_db()) + return JSONResponse([{ + "handle": u.handle, + "display_name": u.display_name, + "is_admin": u.is_admin, + "created_at": u.created_at, + } for u in users]) + + +# ── Write API (ported from bincio edit, auth-gated) ─────────────────────────── + +def _user_data_dir(handle: str) -> Path: + """Return the merged data dir for a user, for reading activity files.""" + dd = _get_data_dir() + merged = dd / handle / "_merged" + return merged if merged.exists() else dd / handle + + +def _require_owns(activity_id: str, user: User) -> Path: + """Verify the user owns this activity (it lives in their data dir).""" + activity_path = _user_data_dir(user.handle) / "activities" / f"{activity_id}.json" + if not activity_path.exists(): + raise HTTPException(404, "Activity not found") + return activity_path + + +@app.get("/api/activity/{activity_id}") +async def get_activity( + activity_id: str, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + user = _require_user(bincio_session) + path = _require_owns(activity_id, user) + return JSONResponse(json.loads(path.read_text())) + + +@app.post("/api/activity/{activity_id}") +async def post_activity( + activity_id: str, + request: Request, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + user = _require_user(bincio_session) + dd = _get_data_dir() / user.handle + + from bincio.edit.server import _apply_sidecar_edit # type: ignore[attr-defined] + body = await request.json() + _apply_sidecar_edit(activity_id, body, dd) + _trigger_rebuild(user.handle) + return JSONResponse({"ok": True}) + + +@app.post("/api/strava/sync") +async def strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + user = _require_user(bincio_session) + dd = _get_data_dir() / user.handle + # Delegate to edit server logic but using user's data dir + 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: + result = await _sync() + finally: + edit_srv.data_dir = old + _trigger_rebuild(user.handle) + return result diff --git a/pyproject.toml b/pyproject.toml index 69dabb5..34004a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,12 @@ edit = [ "uvicorn[standard]>=0.29", "python-multipart>=0.0.9", ] +serve = [ + "fastapi>=0.110", + "uvicorn[standard]>=0.29", + "python-multipart>=0.0.9", + "bcrypt>=4.1", +] strava = [ "requests>=2.32", ] diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 94b8c2e..ef14228 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -1,7 +1,11 @@ import { defineConfig } from "astro/config"; +import { loadEnv } from "vite"; import svelte from "@astrojs/svelte"; import tailwind from "@astrojs/tailwind"; +const env = loadEnv(process.env.NODE_ENV ?? 'development', process.cwd(), ''); +const serveTarget = env.PUBLIC_EDIT_URL || 'http://localhost:4041'; + export default defineConfig({ integrations: [svelte(), tailwind()], devToolbar: { enabled: false }, @@ -14,5 +18,15 @@ export default defineConfig({ esbuildOptions: { target: 'es2022' }, }, build: { target: 'es2022' }, + // Proxy /api/* to bincio serve/edit so cookies work same-origin in dev. + // In production nginx handles this — same pattern, no code change needed. + server: { + proxy: { + '/api': { + target: serveTarget, + changeOrigin: true, + }, + }, + }, }, }); diff --git a/site/src/components/ActivityFeed.svelte b/site/src/components/ActivityFeed.svelte index 3d3e466..a04dbf7 100644 --- a/site/src/components/ActivityFeed.svelte +++ b/site/src/components/ActivityFeed.svelte @@ -28,6 +28,13 @@ .join(' '); } + /** Base URL of the site (passed from Astro). */ + export let base: string = '/'; + /** When set, load this index URL instead of the root (for per-user profile pages). */ + export let profileIndexUrl: string = ''; + /** When set, only show activities from this handle. */ + export let filterHandle: string = ''; + const PAGE_SIZE = 60; let all: ActivitySummary[] = []; @@ -54,8 +61,13 @@ sport = (new URLSearchParams(window.location.search).get('sport') as Sport | 'all') ?? 'all'; mounted = true; try { - const index = await loadIndex(import.meta.env.BASE_URL); - all = index.activities.filter(a => a.privacy !== 'private'); + const indexUrl = profileIndexUrl + ? `${base}data/${profileIndexUrl}` + : `${base}data/index.json`; + const index = await loadIndex(base, indexUrl); + let activities = index.activities.filter(a => a.privacy !== 'private'); + if (filterHandle) activities = activities.filter(a => a.handle === filterHandle); + all = activities; } catch (e: any) { error = e.message; } finally { @@ -117,7 +129,9 @@
-

{formatDate(a.started_at)}

+

+ {formatDate(a.started_at)}{#if a.handle} · @{a.handle}{/if} +

{a.title}

diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 274b48a..4547def 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -1,11 +1,31 @@ --- +import { readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + interface Props { title?: string; description?: string; + /** Set true on pages that must remain accessible without auth (login, register). */ + public?: boolean; } -const { title = 'BincioActivity', description = 'Your personal activity stats' } = Astro.props; +const { title = 'BincioActivity', description = 'Your personal activity stats', public: isPublicPage = false } = Astro.props; const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? ''; const baseUrl = import.meta.env.BASE_URL ?? '/'; + +// Detect whether this instance is private (multi-user, requires login to view). +let instancePrivate = false; +try { + const candidates = [ + process.env.BINCIO_DATA_DIR, + resolve(process.cwd(), 'public', 'data'), + resolve(process.cwd(), '..', 'bincio_data'), + ].filter(Boolean) as string[]; + const dataDir = candidates.find(d => { try { readFileSync(join(d, 'index.json')); return true; } catch { return false; } }); + if (dataDir) { + const root = JSON.parse(readFileSync(join(dataDir, 'index.json'), 'utf-8')); + instancePrivate = root?.instance?.private === true; + } +} catch { /* non-fatal */ } --- @@ -28,6 +48,15 @@ const baseUrl = import.meta.env.BASE_URL ?? '/'; }); + + {instancePrivate && !isPublicPage && ( + + )} +