towards multi-user

This commit is contained in:
Davide Scaini
2026-04-08 19:37:10 +02:00
parent 36a91362d9
commit f76cc0ce7e
18 changed files with 1248 additions and 30 deletions
+4
View File
@@ -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)
+80 -10
View File
@@ -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)}
View File
+47
View File
@@ -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")
+265
View File
@@ -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"],
)
+84
View File
@@ -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",
))
+311
View File
@@ -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