unify single user and multi user behaviour

This commit is contained in:
Davide Scaini
2026-04-09 08:58:35 +02:00
parent 2007f53580
commit 98c42dc443
25 changed files with 678 additions and 232 deletions
+3
View File
@@ -34,5 +34,8 @@ extract_config.yaml
site/android/
site/ios/
# Data
data/*
# OS
.DS_Store
+14 -16
View File
@@ -13,7 +13,8 @@ Anyone can publish their data as BAS JSON and others can include it.
## Key design decisions
- **No database, no server** — everything is static files in single-user mode; multi-user VPS mode adds SQLite auth only
- **Unified data layout** — single-user and multi-user share the same structure: activities always live in `{data-root}/{handle}/`. The only difference is the presence of `instance.db` (auth). No mode switching, no migration.
- **No database, no server** — everything is static files; multi-user VPS mode adds SQLite auth only
- **Python with uv** for the extract stage
- **Astro + Svelte + Tailwind + MapLibre GL + Observable Plot** for the site
- **Haversine** (not geopy) for distance calculations (10x faster)
@@ -100,24 +101,21 @@ site/ Astro project
## How to run
```bash
# Extract
# Single-user (no login)
cd ~/src/bincio_activity
uv run bincio extract --input ~/your-activity-data/activities --output /tmp/bincio_test
uv run bincio extract --output /tmp/bincio_test # → /tmp/bincio_test/{handle}/
uv run bincio dev --data-dir /tmp/bincio_test
# → http://localhost:4321/u/{handle}/
# Site dev server (single-user)
uv run bincio render --data-dir /tmp/bincio_test --serve
# → http://localhost:4321
# Multi-user (with login)
uv run bincio init --data-dir /tmp/bincio_test --handle dave
uv run bincio extract --output /tmp/bincio_test # → /tmp/bincio_test/dave/
uv run bincio dev --data-dir /tmp/bincio_test
# → http://localhost:4321 (login required)
# Edit server (optional — enables edit drawer in the site)
uv run bincio edit --data-dir /tmp/bincio_test
# set PUBLIC_EDIT_URL=http://localhost:4041 in site/.env
# Multi-user local test
uv run bincio init --data-dir /tmp/bincio_test --handle dave --password test
uv run bincio render --data-dir /tmp/bincio_test --site-dir site --serve # terminal 1
uv run bincio serve --data-dir /tmp/bincio_test # terminal 2
# site/.env: BINCIO_DATA_DIR=/tmp/bincio_test, PUBLIC_EDIT_URL= (empty)
# astro.config.mjs Vite proxy forwards /api/* → localhost:4041
# bincio dev does everything: merges sidecars, writes manifest,
# symlinks public/data, starts bincio serve (if instance.db exists),
# starts astro dev. Ctrl+C stops all.
# Tests
uv run pytest
+2
View File
@@ -17,6 +17,7 @@ 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
from bincio.dev import dev # noqa: E402
main.add_command(extract)
main.add_command(render)
@@ -24,3 +25,4 @@ main.add_command(edit)
main.add_command(import_group)
main.add_command(init)
main.add_command(serve)
main.add_command(dev)
+165
View File
@@ -0,0 +1,165 @@
"""bincio dev — start the full local development environment.
Runs bincio serve (API) in a background thread and astro dev in the
foreground. One command replaces the two-terminal setup.
"""
from __future__ import annotations
import os
import subprocess
import sys
import threading
from pathlib import Path
from typing import Optional
import click
from rich.console import Console
console = Console()
def _find_site_dir(explicit: Optional[str]) -> Path:
if explicit:
p = Path(explicit).expanduser().resolve()
if not (p / "package.json").exists():
raise click.UsageError(f"No package.json in --site-dir {p}")
return p
for candidate in [Path.cwd() / "site", Path.cwd().parent / "site"]:
if (candidate / "package.json").exists():
return candidate
raise click.UsageError(
"Could not find the Astro site directory. Pass --site-dir."
)
def _find_data_dir(explicit: Optional[str]) -> Path:
if explicit:
return Path(explicit).expanduser().resolve()
auto_config = Path.cwd() / "extract_config.yaml"
if auto_config.exists():
import yaml
raw = yaml.safe_load(auto_config.read_text()) or {}
out = raw.get("output", {}).get("dir")
if out:
return Path(out).expanduser().resolve()
for candidate in [Path.cwd() / "bincio_data", Path.cwd().parent / "bincio_data"]:
if candidate.exists() and _user_dirs(candidate):
return candidate
raise click.UsageError(
"Could not find a data directory with user subdirectories. "
"Run `bincio extract` first, or pass --data-dir."
)
def _ensure_npm(site: Path) -> None:
if not (site / "node_modules").exists():
console.print("Running [cyan]npm install[/cyan]…")
subprocess.run(["npm", "install"], cwd=site, check=True)
def _user_dirs(data: Path) -> list[Path]:
return sorted(p for p in data.iterdir() if p.is_dir() and (p / "activities").exists())
def _merge_all_users(data: Path) -> None:
from bincio.render.cli import _merge_edits, _write_root_manifest
_merge_edits(data)
_write_root_manifest(data)
def _start_serve(data: Path, api_port: int, site: Path) -> None:
"""Start bincio serve in a background thread."""
import uvicorn
import bincio.serve.server as srv
srv.data_dir = data
srv.site_dir = site
config = uvicorn.Config(
srv.app,
host="127.0.0.1",
port=api_port,
log_level="warning", # quiet — astro dev output takes priority
)
server = uvicorn.Server(config)
server.run()
@click.command("dev")
@click.option("--data-dir", default=None, help="BAS data directory (must contain instance.db)")
@click.option("--site-dir", default=None, help="Astro project directory (default: ./site)")
@click.option("--port", default=4321, show_default=True, help="Astro dev server port")
@click.option("--api-port", default=4041, show_default=True, help="bincio serve API port")
def dev(
data_dir: Optional[str],
site_dir: Optional[str],
port: int,
api_port: int,
) -> None:
"""Start the local dev environment: bincio serve + astro dev.
Equivalent to running both servers manually in two terminals.
Requires `bincio init` to have been run first.
\b
Quick start:
uv run bincio init --data-dir ./data --handle you --password secret
uv run bincio extract --output ./data/you
uv run bincio dev --data-dir ./data
"""
data = _find_data_dir(data_dir)
site = _find_site_dir(site_dir)
has_auth = (data / "instance.db").exists()
console.print(f"[bold]bincio dev[/bold]")
console.print(f" Data: [cyan]{data}[/cyan]")
console.print(f" Site: [cyan]{site}[/cyan]")
if has_auth:
console.print(f" API: [cyan]http://127.0.0.1:{api_port}[/cyan]")
else:
console.print(f" Auth: [yellow]none[/yellow] (single-user, no instance.db)")
console.print(f" Browser: [cyan]http://localhost:{port}[/cyan]")
console.print()
_ensure_npm(site)
console.print("Merging sidecars…")
_merge_all_users(data)
# Symlink site/public/data → data dir
public_data = site / "public" / "data"
public_data.parent.mkdir(parents=True, exist_ok=True)
if public_data.is_symlink():
if public_data.resolve() != data.resolve():
public_data.unlink()
public_data.symlink_to(data)
elif not public_data.exists():
public_data.symlink_to(data)
# Start bincio serve only when instance.db exists (auth / write API)
if has_auth:
console.print(f"Starting [cyan]bincio serve[/cyan] on port {api_port}")
t = threading.Thread(target=_start_serve, args=(data, api_port, site), daemon=True)
t.start()
# Build env for astro dev
env = {
**os.environ,
"BINCIO_DATA_DIR": str(data),
"PUBLIC_EDIT_URL": "", # empty = proxy /api/* to bincio serve
"VITE_API_PORT": str(api_port), # picked up by astro.config.mjs if needed
}
# Start astro dev in foreground (Ctrl+C stops everything)
console.print(f"Starting [cyan]astro dev[/cyan] on port {port}")
console.print()
try:
subprocess.run(
["npm", "run", "dev", "--", "--port", str(port)],
cwd=site,
env=env,
)
except KeyboardInterrupt:
pass
+4
View File
@@ -342,6 +342,10 @@ def _resolve_config(
cfg.input_dirs = [Path(input_dir).expanduser()]
if output_dir:
cfg.output_dir = Path(output_dir).expanduser()
# Always write into {data_root}/{handle}/ so the data dir is always
# instance-rooted and single/multi-user share the same layout.
if cfg.output_dir.name != cfg.owner_handle:
cfg.output_dir = cfg.output_dir / cfg.owner_handle
return cfg
+1 -22
View File
@@ -70,10 +70,6 @@ def _ensure_npm(site: Path) -> None:
subprocess.run(["npm", "install"], cwd=site, check=True)
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(
@@ -86,7 +82,6 @@ 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
if _is_multiuser(data):
targets = [data / handle] if handle else _user_dirs(data)
total = 0
for user_dir in targets:
@@ -95,12 +90,6 @@ def _merge_edits(data: Path, handle: str | None = None) -> None:
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:
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:
@@ -138,13 +127,8 @@ def _write_root_manifest(data: Path) -> None:
def _link_data(site: Path, data: Path) -> None:
"""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/)
"""Symlink site/public/data → data root (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():
@@ -193,13 +177,8 @@ 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, handle=handle)
if multiuser:
_write_root_manifest(data)
_link_data(site, data)
+12 -9
View File
@@ -152,7 +152,7 @@ async def me(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRespon
@app.post("/api/auth/login")
async def login(request: Request, response: Response) -> JSONResponse:
async def login(request: Request) -> JSONResponse:
ip = request.client.host if request.client else "unknown"
_check_rate_limit(ip)
@@ -165,22 +165,24 @@ async def login(request: Request, response: Response) -> JSONResponse:
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})
resp = JSONResponse({"ok": True, "handle": user.handle, "display_name": user.display_name})
_set_session_cookie(resp, token)
return resp
@app.post("/api/auth/logout")
async def logout(response: Response, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
async def logout(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})
resp = JSONResponse({"ok": True})
resp.delete_cookie(_SESSION_COOKIE)
return resp
# ── Registration ──────────────────────────────────────────────────────────────
@app.post("/api/register")
async def register(request: Request, response: Response) -> JSONResponse:
async def register(request: Request) -> JSONResponse:
body = await request.json()
code = body.get("code", "").strip().upper()
handle = body.get("handle", "").strip().lower()
@@ -207,8 +209,9 @@ async def register(request: Request, response: Response) -> JSONResponse:
(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})
resp = JSONResponse({"ok": True, "handle": handle})
_set_session_cookie(resp, token)
return resp
# ── Invites ───────────────────────────────────────────────────────────────────
+20 -15
View File
@@ -96,9 +96,27 @@ index.json
## Deployment modes
Single-user and multi-user share the same data layout. The only difference is whether `instance.db` exists (which enables auth).
### Data layout (always)
```
{data-root}/
index.json ← shard manifest (always; one shard for single-user)
instance.db ← SQLite auth (only in multi-user, created by bincio init)
{handle}/
index.json ← user's BAS feed
_merged/ ← sidecar-merged output
activities/
edits/
athlete.json
```
### Single-user (static)
No server process required. Run `bincio render`, drop `site/dist/` anywhere. The edit drawer requires `bincio edit` running locally and `PUBLIC_EDIT_URL` set in `site/.env`.
No login, no server. Run `bincio dev --data-dir {root}` or `bincio render`, drop `site/dist/` anywhere. The site opens directly at `/u/{handle}/`. The "Feed" tab (combined feed) is hidden — there's only one user.
The edit drawer requires `bincio edit` running locally and `PUBLIC_EDIT_URL` set in `site/.env`.
### Multi-user (VPS)
@@ -113,20 +131,7 @@ nginx / caddy
`bincio serve` is a FastAPI application that owns auth, user management, and write operations. It never serves static files. nginx handles TLS and static file serving.
Data is partitioned per user:
```
/data/
instance.db ← SQLite: users, sessions, invites
index.json ← root shard manifest (no activity data)
{handle}/
index.json ← user's BAS feed
_merged/ ← sidecar-merged output
activities/
edits/
```
The root `index.json` is a shard manifest that lists user shard URLs. The browser resolves all shards concurrently and merges them into a single feed.
The root `index.json` shard manifest lists all user shard URLs. The browser resolves them concurrently and merges activities into a combined feed at `/`.
### Instance privacy
+44 -50
View File
@@ -1,6 +1,6 @@
# Multi-user deployment
Multiple users share one bincio instance. Activities are public within the instance by default. The `private` flag hides individual activities. The whole instance requires login to view (private by default).
Multiple users share one bincio instance. The whole instance requires login to view (private by default). Activities are visible to all logged-in users; the `private` flag hides individual activities.
## Architecture
@@ -15,12 +15,12 @@ internet
`bincio serve` owns all dynamic behaviour — auth, user management, write operations. nginx serves static files and proxies API routes. `bincio serve` never handles static files.
Sessions are httpOnly cookies (`bincio_session`), stored in SQLite. The Astro site calls `GET /api/me` on page load to detect the logged-in user.
Sessions are httpOnly cookies (`bincio_session`), stored in SQLite. The Astro site calls `GET /api/me` on page load to detect the logged-in user and update nav links.
## Data layout
```
/data/ ← BINCIO_DATA_DIR
/data/ ← instance root
instance.db ← SQLite: users, sessions, invites
index.json ← shard manifest (no activity data)
{handle}/
@@ -28,10 +28,13 @@ Sessions are httpOnly cookies (`bincio_session`), stored in SQLite. The Astro si
_merged/ ← sidecar-merged output (served to browser)
activities/
edits/
athlete.json
strava_token.json
```
The root `index.json` is a shard manifest — it lists user shard URLs but contains no activity data. Each user's `{handle}/index.json` is a valid standalone BAS feed (usable for federation). The browser resolves shards concurrently and merges them.
The root `index.json` is a shard manifest — it lists user shard URLs but contains no activity data. Each user's `{handle}/index.json` is a valid standalone BAS feed. The browser resolves all shards concurrently and merges them into a combined feed.
This is the same layout used for single-user deployments — the only addition is `instance.db`.
## Step 1 — Initialise the instance
@@ -41,9 +44,9 @@ uv sync --extra serve
uv run bincio init \
--data-dir /var/bincio \
--handle dave \
--password 'your-password' \
--display-name "Dave" \
--name "My Bincio"
# prompted for password
```
This creates:
@@ -56,10 +59,11 @@ This creates:
## Step 2 — Extract activities
Pass the **instance root** to `--output`. The handle is appended automatically:
```bash
uv run bincio extract \
--input ~/activity-files \
--output /var/bincio/dave
uv run bincio extract --output /var/bincio
# → writes to /var/bincio/dave/
```
## Step 3 — Build the site
@@ -74,17 +78,16 @@ uv run bincio render \
# Output: site/dist/
```
In multi-user mode, `bincio render`:
- Runs `merge_all()` for each user's directory
- Rewrites the root `index.json` shard manifest
- Symlinks `site/public/data → /var/bincio`
- Builds the Astro site
`bincio render` always:
1. Runs `merge_all()` for each user's directory
2. Rewrites the root `index.json` shard manifest
3. Symlinks `site/public/data → /var/bincio`
4. Builds the Astro site
Incremental rebuild (one user only):
Incremental rebuild (one user only, no full site rebuild):
```bash
uv run bincio render --data-dir /var/bincio --handle dave
# Re-merges dave's shard, rewrites root manifest — does not rebuild the site
```
## Step 4 — Configure nginx
@@ -136,14 +139,29 @@ Restart=on-failure
WantedBy=multi-user.target
```
## Inviting users
After initialising, `bincio init` prints an invite code. To generate more:
## Local testing (before deploying)
```bash
# From the admin account, via the browser at /invites/
# Or directly in the database:
python3 -c "
# 1. Initialise the instance
uv run bincio init --data-dir /tmp/bincio_test --handle dave
# 2. Extract activities (pass instance root, not user dir)
uv run bincio extract --output /tmp/bincio_test
# → writes to /tmp/bincio_test/dave/
# 3. Start everything with one command
uv run bincio dev --data-dir /tmp/bincio_test
# → http://localhost:4321
```
`bincio dev` detects `instance.db`, starts `bincio serve` (port 4041) in the background and `astro dev` (port 4321) in the foreground. No `.env` file needed. Ctrl+C stops both.
## Inviting users
After initialising, `bincio init` prints a first invite code. Generate more from the browser at `/invites/`, or directly:
```bash
uv run python -c "
from pathlib import Path
from bincio.serve.db import open_db, create_invite
db = open_db(Path('/var/bincio'))
@@ -153,42 +171,17 @@ print(create_invite(db, 'dave'))
Share the invite link: `https://example.com/register/?code=XXXXXXXX`
Invite limits: admins — unlimited. Regular users — 3 invites each (configurable in `bincio/serve/db.py`, `_MAX_USER_INVITES`).
Invite limits: admins — unlimited. Regular users — 3 each (configurable via `_MAX_USER_INVITES` in `bincio/serve/db.py`).
## Instance privacy
By default, `bincio init` sets `"private": true` in the root `index.json`. This means every page (except `/login/` and `/register/`) redirects unauthenticated visitors to `/login/`.
`bincio init` sets `"private": true` in the root `index.json` by default. This means every page (except `/login/` and `/register/`) redirects unauthenticated visitors to `/login/`.
To make the instance public, edit `/var/bincio/index.json` and set `"private": false`. The next `bincio render` will preserve this setting.
## Local testing (before deploying)
```bash
# 1. Initialise a test instance
uv run bincio init --data-dir /tmp/bincio_test --handle dave --password test
# 2. Extract activities into the user's dir
uv run bincio extract --input ~/activity-files --output /tmp/bincio_test/dave
# 3. Build + start the dev server (terminal 1)
uv run bincio render --data-dir /tmp/bincio_test --site-dir site --serve
# 4. Start bincio serve (terminal 2)
uv run bincio serve --data-dir /tmp/bincio_test
```
The Astro dev server proxies `/api/*` to `localhost:4041` (configured in `astro.config.mjs`), so cookies work same-origin. Set `site/.env`:
```
BINCIO_DATA_DIR=/tmp/bincio_test
PUBLIC_EDIT_URL=
```
`PUBLIC_EDIT_URL` empty = edit UI enabled via proxy. The edit/upload button appears when `bincio serve` is running. In production nginx plays the same proxy role.
To make the instance public, edit the root `index.json` and set `"private": false`. The next `bincio render` preserves this setting.
## Per-user Strava sync
Each user connects their own Strava account. The OAuth token is stored in `/var/bincio/{handle}/strava_token.json`. The "Connect Strava" and "Sync" buttons in the upload modal work per-session — each user syncs only their own activities.
Each user connects their own Strava account. The OAuth token is stored in `{handle}/strava_token.json`. The "Sync from Strava" button in the upload modal works per-session — each user syncs only their own activities.
## Federation
@@ -209,5 +202,6 @@ The browser fetches and merges remote shards concurrently. Remote activities app
- [CLI reference — bincio init](../reference/cli.md#bincio-init)
- [CLI reference — bincio serve](../reference/cli.md#bincio-serve)
- [CLI reference — bincio dev](../reference/cli.md#bincio-dev)
- [API reference](../reference/api.md)
- [BAS schema — instance manifest](../../SCHEMA.md#instance-manifest)
+45 -14
View File
@@ -1,32 +1,65 @@
# Single-user deployment
One person, one machine, all your data stays with you. This is the default and simplest mode.
One person, one machine, all your data stays with you. No login, no server process.
## Data layout
All data lives under your instance root in a per-user subdirectory:
```
~/bincio_data/ ← instance root (output.dir in config)
index.json ← shard manifest (generated by bincio render/dev)
yourname/
index.json ← your BAS feed
_merged/ ← sidecar-merged output (served to browser)
activities/
edits/
athlete.json
```
`bincio extract` writes into `yourname/` automatically — pass the instance root to `--output`, not the user directory.
## Local development
```bash
uv run bincio dev --data-dir ~/bincio_data
# → http://localhost:4321/u/yourname/
```
`bincio dev` without an `instance.db` runs in single-user mode: no login, no API server, just `astro dev`.
## GitHub Pages (free, automated)
```bash
uv run bincio render --deploy github
uv run bincio render --data-dir ~/bincio_data --deploy github
```
This builds `site/dist/` and pushes it to the `gh-pages` branch. Requires `npx gh-pages` (`npm install -g gh-pages`).
Builds `site/dist/` and pushes it to the `gh-pages` branch. Requires `npx gh-pages` (`npm install -g gh-pages`).
Set the repository to serve from the `gh-pages` branch in GitHub → Settings → Pages.
## Static hosting (Netlify, Vercel, Cloudflare Pages, etc.)
Build locally and deploy the `site/dist/` directory. Or set up CI:
Build locally and deploy `site/dist/`:
```bash
uv run bincio render --data-dir ~/bincio_data
# upload site/dist/ to your host
```
Or set up CI:
```yaml
# .github/workflows/deploy.yml (example)
- run: uv run bincio render
- run: uv run bincio render --data-dir ~/bincio_data
- uses: actions/upload-pages-artifact@v3
with:
path: site/dist
```
## VPS with nginx
## VPS with nginx (read-only)
Serve `site/dist/` as a static directory. No server process needed for read-only access.
Serve `site/dist/` as a static directory:
```nginx
server {
@@ -40,7 +73,7 @@ server {
### Enable the edit UI on a VPS
If you want to edit activities from the browser while on your VPS:
To edit activities from the browser on your VPS, run `bincio edit` and proxy `/api/*` to it:
```nginx
server {
@@ -53,7 +86,6 @@ server {
try_files $uri $uri/ $uri.html =404;
}
# Proxy /api/* to bincio edit (local-only, never exposed directly)
location /api/ {
proxy_pass http://127.0.0.1:4041;
proxy_set_header Host $host;
@@ -61,21 +93,20 @@ server {
}
```
Then run `bincio edit` as a background service:
```bash
uv sync --extra edit
uv run bincio edit --data-dir ~/bincio_data
```
And set `PUBLIC_EDIT_URL=` (empty — the proxy makes /api/ same-origin) in your environment before building.
Set `PUBLIC_EDIT_URL=` (empty — the proxy makes `/api/` same-origin) in your environment before building.
## Keeping the site up to date
After extracting new activities or editing sidecars:
```bash
uv run bincio extract # process new files
uv run bincio render # rebuild site/dist/
uv run bincio extract --data-dir ~/bincio_data # process new files
uv run bincio render --data-dir ~/bincio_data # rebuild site/dist/
rsync -av site/dist/ user@server:/var/www/bincio/dist/
```
+50 -18
View File
@@ -23,11 +23,11 @@ cp extract_config.example.yaml extract_config.yaml
$EDITOR extract_config.yaml
```
Minimum configuration:
Set your handle and input directory at minimum:
```yaml
owner:
handle: yourname # used in URLs and federation
handle: yourname # used in URLs: /u/yourname/
display_name: Your Name
input:
@@ -35,54 +35,86 @@ input:
- ~/your-activity-data/activities
output:
dir: ~/bincio_data
dir: ~/bincio_data # instance root; activities go into ~/bincio_data/yourname/
```
The config file is gitignored — safe to store Strava credentials here.
---
## Extract
```bash
uv run bincio extract
```
This reads all GPX/FIT/TCX files (including `.gz` variants), deduplicates them, and writes a BAS data store to `~/bincio_data/`.
Reads all GPX/FIT/TCX files and writes a BAS data store to `~/bincio_data/yourname/`. Re-running is safe — unchanged files are skipped (hash-based).
Re-running is safe — unchanged files are skipped (hash-based). To force a full re-extract: `rm -rf ~/bincio_data && uv run bincio extract`.
> `--output` overrides `output.dir` from the config and is the **instance root**,
> not the user directory. The handle is always appended automatically:
> `bincio extract --output ~/bincio_data` → writes to `~/bincio_data/yourname/`.
## Build the site
---
## Single-user — no login, static site
```bash
# Build and preview
cd site && npm install && cd ..
cp site/.env.example site/.env
# Edit site/.env: set BINCIO_DATA_DIR=~/bincio_data
uv run bincio render
uv run bincio dev --data-dir ~/bincio_data
# → http://localhost:4321
```
Output is in `site/dist/` — a folder of static files. Drop it anywhere: GitHub Pages, Netlify, a Raspberry Pi, a USB stick.
`bincio dev` merges edits, builds the shard manifest, and starts `astro dev`. No login required — the site opens directly at `/u/yourname/`.
## Dev mode
To build for deployment (no live server):
```bash
uv run bincio render --serve # → http://localhost:4321
uv run bincio render --data-dir ~/bincio_data
# output: site/dist/
```
## Enable the edit UI
See [Single-user deployment](deployment/single-user.md).
The edit UI lets you rename activities, add descriptions, upload photos, and sync from Strava — all from the browser.
---
## Multi-user — shared instance, login required
```bash
uv sync --extra serve
# One-time: create the instance database and admin account
uv run bincio init --data-dir ~/bincio_data --handle yourname
# Start everything
uv run bincio dev --data-dir ~/bincio_data
# → http://localhost:4321 (login with the password set during init)
```
`bincio dev` detects the `instance.db` and automatically starts `bincio serve` alongside `astro dev`. Ctrl+C stops both.
See [Multi-user deployment](deployment/multi-user.md).
---
## Enable the edit UI (single-user)
The edit UI lets you rename activities, add descriptions, upload photos, and sync from Strava — from the browser.
```bash
uv sync --extra edit
uv run bincio edit # starts on http://localhost:4041
uv run bincio edit --data-dir ~/bincio_data
# Add to site/.env:
# PUBLIC_EDIT_URL=http://localhost:4041
```
An Edit button and an Upload ↑ button appear in the nav.
In multi-user mode the edit UI is always available via `bincio serve` — no extra step needed.
---
## Next steps
- [Single-user deployment](deployment/single-user.md) — serve your site on a VPS or GitHub Pages
- [Multi-user deployment](deployment/multi-user.md) — invite friends, shared feed
- [Single-user deployment](deployment/single-user.md) — GitHub Pages, Netlify, VPS
- [Multi-user deployment](deployment/multi-user.md) — VPS with nginx, inviting users
- [CLI reference](reference/cli.md) — all commands and options
- [BAS schema](../SCHEMA.md) — the data format and federation protocol
+45 -3
View File
@@ -4,6 +4,39 @@ All commands are run via `uv run bincio <command>` from the project root.
---
## bincio dev
Start the full local development environment. One command replaces the two-terminal setup.
```bash
uv sync --extra serve
uv run bincio dev [OPTIONS]
```
| Option | Default | Description |
|---|---|---|
| `--data-dir DIR` | auto-detected | BAS data directory (must contain `instance.db`) |
| `--site-dir DIR` | `./site` | Astro project directory |
| `--port PORT` | `4321` | Astro dev server port |
| `--api-port PORT` | `4041` | bincio serve API port |
`bincio dev` runs the following steps automatically:
1. Merges sidecar edits for all users (`merge_all()`)
2. Rewrites the root `index.json` shard manifest
3. Symlinks `site/public/data` → data directory
4. Starts `bincio serve` on `--api-port` in a background thread (**only if `instance.db` exists**)
5. Starts `astro dev` on `--port` in the foreground
No `.env` file needed — `BINCIO_DATA_DIR` and `PUBLIC_EDIT_URL` are set automatically.
Works in both modes:
- **Single-user** (no `instance.db`): no login, no API server, just `astro dev`
- **Multi-user** (`instance.db` present): starts `bincio serve` alongside `astro dev`
Ctrl+C stops everything.
---
## bincio extract
Extract GPX/FIT/TCX files into a BAS data store.
@@ -16,12 +49,21 @@ uv run bincio extract [OPTIONS]
|---|---|---|
| `--config PATH` | `extract_config.yaml` | Path to config file |
| `--input DIR` | from config | Input directory (scanned recursively) |
| `--output DIR` | from config | Output BAS data store directory |
| `--output DIR` | from config | Instance root directory |
| `--file PATH` | — | Extract a single file, print JSON to stdout |
| `--since DATE` | — | Only process files newer than this date (YYYY-MM-DD) |
| `--dev N` | — | Dev mode: sample N files evenly, output to `/tmp/bincio_dev/` |
Extraction is incremental by default — unchanged files (same hash) are skipped. To force a full re-extract: `rm -rf <output_dir>`.
`--output` (and `output.dir` in config) is the **instance root**, not the user directory. The handle from `owner.handle` in `extract_config.yaml` is always appended automatically:
```
bincio extract --output ~/bincio_data
# → writes to ~/bincio_data/{handle}/
```
This applies to both single-user and multi-user setups — the data layout is always the same.
Extraction is incremental by default — unchanged files (same hash) are skipped. To force a full re-extract, delete the user directory: `rm -rf ~/bincio_data/{handle}`.
Supported formats: GPX, FIT, TCX — all with optional `.gz` compression.
@@ -46,7 +88,7 @@ uv run bincio render [OPTIONS]
`bincio render` always:
1. Runs `merge_all()` — applies sidecar edits, produces `_merged/`
2. (Multi-user) Rewrites the root `index.json` shard manifest
2. Rewrites the root `index.json` shard manifest
3. Symlinks `site/public/data` → data directory
4. Runs `astro build` (or `astro dev` with `--serve`)
+4 -1
View File
@@ -4,7 +4,10 @@ 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';
// PUBLIC_EDIT_URL: non-empty → bincio edit URL; empty → proxy to bincio serve.
// VITE_API_PORT lets `bincio dev` override the serve port without touching .env.
const apiPort = process.env.VITE_API_PORT || '4041';
const serveTarget = env.PUBLIC_EDIT_URL || `http://localhost:${apiPort}`;
export default defineConfig({
integrations: [svelte(), tailwind()],
+6 -2
View File
@@ -7,6 +7,10 @@
import { loadIndex, loadAthlete } from '../lib/dataloader';
export let base: string = '/';
/** Explicit index URL for multi-user per-user pages (user's shard). */
export let indexUrl: string = '';
/** Explicit athlete.json URL for multi-user per-user pages. */
export let athleteUrl: string = '';
let athlete: AthleteJson | null = null;
let activities: ActivitySummary[] = [];
@@ -34,8 +38,8 @@
mounted = true;
try {
const [athleteData, index] = await Promise.all([
loadAthlete(import.meta.env.BASE_URL),
loadIndex(import.meta.env.BASE_URL),
loadAthlete(import.meta.env.BASE_URL, athleteUrl || undefined),
loadIndex(import.meta.env.BASE_URL, indexUrl || undefined),
]);
if (!athleteData) throw new Error('athlete.json not found — run bincio extract first');
athlete = athleteData;
+4 -1
View File
@@ -4,6 +4,9 @@
import { formatDistance, formatDuration, sportIcon, sportColor, sportLabel } from '../lib/format';
import { loadIndex } from '../lib/dataloader';
/** Explicit index URL — use for per-user stats pages in multi-user mode. */
export let indexUrl: string = '';
const PAGE_YEARS = 4;
let all: ActivitySummary[] = [];
@@ -31,7 +34,7 @@
page = parseInt(params.get('page') ?? '0', 10) || 0;
mounted = true;
try {
const index = await loadIndex(import.meta.env.BASE_URL);
const index = await loadIndex(import.meta.env.BASE_URL, indexUrl || undefined);
all = index.activities.filter(a => a.privacy !== 'private' && a.distance_m);
} catch (e: any) {
error = e.message;
+59 -4
View File
@@ -12,8 +12,9 @@ const { title = 'BincioActivity', description = 'Your personal activity stats',
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).
// Read root index.json at build time to detect instance configuration.
let instancePrivate = false;
let singleHandle: string | null = null; // set when there is exactly one shard
try {
const candidates = [
process.env.BINCIO_DATA_DIR,
@@ -24,6 +25,9 @@ try {
if (dataDir) {
const root = JSON.parse(readFileSync(join(dataDir, 'index.json'), 'utf-8'));
instancePrivate = root?.instance?.private === true;
const shards: Array<{ handle?: string }> = root?.shards ?? [];
const handles = shards.map(s => s.handle).filter(Boolean);
if (handles.length === 1) singleHandle = handles[0] as string;
}
} catch { /* non-fatal */ }
---
@@ -137,13 +141,29 @@ try {
<a href={baseUrl} class="font-bold text-white tracking-tight hover:text-[--accent] transition-colors">
Bincio<span class="text-[--accent]">Activity</span>
</a>
<!-- Feed tab: only shown for multi-user (more than one shard) -->
{!singleHandle && (
<a href={baseUrl} class="text-sm text-zinc-400 hover:text-white transition-colors">Feed</a>
<a href={`${baseUrl}stats/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Stats</a>
<a href={`${baseUrl}athlete/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Athlete</a>
<a href={`${baseUrl}record/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Record</a>
)}
<!-- Single-user: static handle link. Multi-user: populated by user-widget script. -->
{singleHandle
? <a href={`${baseUrl}u/${singleHandle}/`} class="text-sm text-zinc-400 hover:text-white transition-colors">@{singleHandle}</a>
: <a id="nav-me" href="#" style="display:none" class="text-sm text-[--accent] hover:text-white transition-colors"></a>
}
<!-- Per-user nav links — updated by user-widget script in multi-user mode -->
<a id="nav-stats" href={singleHandle ? `${baseUrl}u/${singleHandle}/stats/` : `${baseUrl}stats/`} data-user-path="stats/" class="text-sm text-zinc-400 hover:text-white transition-colors">Stats</a>
<a id="nav-athlete" href={singleHandle ? `${baseUrl}u/${singleHandle}/athlete/` : `${baseUrl}athlete/`} data-user-path="athlete/" class="text-sm text-zinc-400 hover:text-white transition-colors">Athlete</a>
<a id="nav-record" href={singleHandle ? `${baseUrl}u/${singleHandle}/record/` : `${baseUrl}record/`} data-user-path="record/" class="text-sm text-zinc-400 hover:text-white transition-colors">Record</a>
<a href={`${baseUrl}convert/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Convert</a>
<div class="ml-auto flex items-center gap-1">
<!-- Logout button — hidden until logged in -->
<button
id="nav-logout"
style="display:none"
class="text-xs text-zinc-500 hover:text-white transition-colors px-2 h-8"
aria-label="Log out"
>Log out</button>
{editUrl && (
<button
id="upload-btn"
@@ -283,6 +303,41 @@ try {
});
</script>
<!-- User widget: only needed for multi-user (single-user nav links are static) -->
{!singleHandle && (
<script define:vars={{ baseUrl }}>
(async () => {
try {
const r = await fetch('/api/me', { credentials: 'include' });
if (!r.ok) return;
const user = await r.json();
// Show @handle link → /u/{handle}/
const meEl = document.getElementById('nav-me');
if (meEl) {
meEl.textContent = '@' + user.handle;
meEl.href = baseUrl + 'u/' + user.handle + '/';
meEl.style.display = '';
}
// Update per-user nav links to point to /u/{handle}/{page}/
document.querySelectorAll('[data-user-path]').forEach(el => {
el.href = baseUrl + 'u/' + user.handle + '/' + el.getAttribute('data-user-path');
});
// Show logout button
const logoutEl = document.getElementById('nav-logout');
if (logoutEl) logoutEl.style.display = '';
} catch (_) {}
})();
document.getElementById('nav-logout')?.addEventListener('click', async () => {
try { await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }); } catch (_) {}
window.location.href = baseUrl + 'login/';
});
</script>
)}
{editUrl && (
<script define:vars={{ editUrl, baseUrl }}>
const modal = document.getElementById('upload-modal');
+8 -2
View File
@@ -161,10 +161,16 @@ export async function loadActivity(
/**
* Load athlete profile. Athlete data is not stored locally yet, so this is
* always a network fetch with a graceful null on failure.
*
* @param baseUrl Site base URL (used to build the default path)
* @param athleteUrl Explicit full URL — use for per-user pages in multi-user mode
*/
export async function loadAthlete(baseUrl: string): Promise<Record<string, unknown> | null> {
export async function loadAthlete(
baseUrl: string,
athleteUrl?: string,
): Promise<Record<string, unknown> | null> {
try {
return await fetchJSON(`${baseUrl}data/athlete.json`);
return await fetchJSON(athleteUrl ?? `${baseUrl}data/athlete.json`);
} catch {
return null;
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Build-time helpers for reading the BAS shard manifest.
* Only import this in .astro frontmatter — it uses Node.js APIs.
*/
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
export function findDataDir(): string | null {
const candidates = [
process.env.BINCIO_DATA_DIR,
resolve(process.cwd(), 'public', 'data'),
resolve(process.cwd(), '..', 'bincio_data'),
].filter(Boolean) as string[];
return candidates.find(d => {
try { readFileSync(join(d, 'index.json')); return true; } catch { return false; }
}) ?? null;
}
export interface ShardHandle {
handle: string;
/** Shard URL as written in the manifest (relative to data root). */
url: string;
}
export function readShardHandles(): ShardHandle[] {
try {
const dataDir = findDataDir();
if (!dataDir) return [];
const root = JSON.parse(readFileSync(join(dataDir, 'index.json'), 'utf-8'));
const shards: Array<{ handle?: string; url: string }> = root.shards ?? [];
return shards
.filter(s => !!s.handle)
.map(s => ({ handle: s.handle!, url: s.url }));
} catch {
return [];
}
}
+14 -6
View File
@@ -1,9 +1,17 @@
---
import Base from '../../layouts/Base.astro';
import AthleteView from '../../components/AthleteView.svelte';
/**
* Legacy route — redirects to /u/{handle}/athlete/
*/
import { readShardHandles } from '../../lib/manifest';
const base = import.meta.env.BASE_URL;
const shards = readShardHandles();
const handle = shards[0]?.handle ?? null;
---
<Base title="Athlete — BincioActivity">
<h1 class="text-2xl font-bold text-white mb-6">Athlete</h1>
<AthleteView {base} client:load />
</Base>
{handle ? (
<meta http-equiv="refresh" content={`0;url=${base}u/${handle}/athlete/`} />
<script define:vars={{ base, handle }}>
window.location.replace(base + 'u/' + handle + '/athlete/');
</script>
) : (
<p>No data found. Run <code>bincio extract</code> first.</p>
)}
+16 -2
View File
@@ -1,8 +1,22 @@
---
import Base from '../layouts/Base.astro';
import ActivityFeed from '../components/ActivityFeed.svelte';
import { readShardHandles } from '../lib/manifest';
const base = import.meta.env.BASE_URL;
const shards = readShardHandles();
const isSingleUser = shards.length === 1;
const singleHandle = isSingleUser ? shards[0].handle : null;
---
{isSingleUser ? (
<!-- Single-user: redirect / → /u/{handle}/ -->
<meta http-equiv="refresh" content={`0;url=${base}u/${singleHandle}/`} />
<script define:vars={{ base, singleHandle }}>
window.location.replace(base + 'u/' + singleHandle + '/');
</script>
) : (
<Base title="BincioActivity — Feed">
<h1 class="text-2xl font-bold text-white mb-6">Activities</h1>
<ActivityFeed client:load />
<h1 class="text-2xl font-bold text-white mb-6">Feed</h1>
<ActivityFeed {base} client:only="svelte" />
</Base>
)}
+16 -6
View File
@@ -1,8 +1,18 @@
---
import Base from '../../layouts/Base.astro';
import StatsView from '../../components/StatsView.svelte';
/**
* Legacy route — redirects to /u/{handle}/stats/
* In multi-user mode the user-widget script in the nav handles the correct link.
*/
import { readShardHandles } from '../../lib/manifest';
const base = import.meta.env.BASE_URL;
const shards = readShardHandles();
const handle = shards[0]?.handle ?? null;
---
<Base title="Stats — BincioActivity">
<h1 class="text-2xl font-bold text-white mb-6">Stats</h1>
<StatsView client:load />
</Base>
{handle ? (
<meta http-equiv="refresh" content={`0;url=${base}u/${handle}/stats/`} />
<script define:vars={{ base, handle }}>
window.location.replace(base + 'u/' + handle + '/stats/');
</script>
) : (
<p>No data found. Run <code>bincio extract</code> first.</p>
)}
-48
View File
@@ -1,48 +0,0 @@
---
/**
* Per-user profile page: /u/{handle}/
*
* In multi-user mode, getStaticPaths reads the root index.json shard manifest
* to discover all handles. In single-user mode this page is never generated.
*/
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import Base from '../../layouts/Base.astro';
import ActivityFeed from '../../components/ActivityFeed.svelte';
export async function getStaticPaths() {
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) return [];
const root = JSON.parse(readFileSync(join(dataDir, 'index.json'), 'utf-8'));
const shards: Array<{ handle?: string; url: string }> = root.shards ?? [];
const handles = shards.map(s => s.handle).filter(Boolean) as string[];
return handles.map(handle => ({
params: { handle },
props: { handle, indexUrl: `${handle}/index.json` },
}));
} catch {
return [];
}
}
const { handle, indexUrl } = Astro.props as { handle: string; indexUrl: string };
const base = import.meta.env.BASE_URL;
---
<Base title={`@${handle} — BincioActivity`}>
<div class="max-w-5xl mx-auto px-4 pt-6 pb-2">
<h1 class="text-2xl font-bold text-white mb-1">@{handle}</h1>
<p class="text-zinc-500 text-sm">Activities by this user</p>
</div>
<ActivityFeed {base} filterHandle={handle} profileIndexUrl={indexUrl} client:only="svelte" />
</Base>
@@ -0,0 +1,32 @@
---
/**
* Per-user athlete page: /u/{handle}/athlete/
*/
import Base from '../../../../layouts/Base.astro';
import AthleteView from '../../../../components/AthleteView.svelte';
import { readShardHandles } from '../../../../lib/manifest';
export function getStaticPaths() {
return readShardHandles().map(({ handle }) => ({
params: { handle },
props: { handle },
}));
}
const { handle } = Astro.props as { handle: string };
const base = import.meta.env.BASE_URL;
const mergedBase = `${base}data/${handle}/_merged/`;
const indexUrl = `${mergedBase}index.json`;
const athleteUrl = `${mergedBase}athlete.json`;
---
<Base title={`@${handle} Athlete — BincioActivity`}>
<div class="max-w-5xl mx-auto px-4 pt-6 pb-2">
<h1 class="text-2xl font-bold text-white mb-0.5">@{handle}</h1>
<nav class="flex gap-4 mt-1 mb-6">
<a href={`${base}u/${handle}/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Feed</a>
<a href={`${base}u/${handle}/stats/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Stats</a>
<a href={`${base}u/${handle}/athlete/`} class="text-sm text-[--accent]">Athlete</a>
</nav>
</div>
<AthleteView {base} {indexUrl} {athleteUrl} client:only="svelte" />
</Base>
+34
View File
@@ -0,0 +1,34 @@
---
/**
* Per-user profile / feed page: /u/{handle}/
*
* Shows only this user's activities. In multi-user mode, getStaticPaths
* reads the root shard manifest to discover all handles.
*/
import Base from '../../../layouts/Base.astro';
import ActivityFeed from '../../../components/ActivityFeed.svelte';
import { readShardHandles } from '../../../lib/manifest';
export function getStaticPaths() {
return readShardHandles().map(({ handle, url }) => ({
params: { handle },
props: { handle, shardUrl: url },
}));
}
const { handle, shardUrl } = Astro.props as { handle: string; shardUrl: string };
const base = import.meta.env.BASE_URL;
---
<Base title={`@${handle} — BincioActivity`}>
<div class="max-w-5xl mx-auto px-4 pt-6 pb-2 flex items-center gap-4">
<div>
<h1 class="text-2xl font-bold text-white mb-0.5">@{handle}</h1>
<nav class="flex gap-4 mt-1">
<a href={`${base}u/${handle}/`} class="text-sm text-[--accent]">Feed</a>
<a href={`${base}u/${handle}/stats/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Stats</a>
<a href={`${base}u/${handle}/athlete/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Athlete</a>
</nav>
</div>
</div>
<ActivityFeed {base} filterHandle={handle} profileIndexUrl={shardUrl} client:only="svelte" />
</Base>
@@ -0,0 +1,30 @@
---
/**
* Per-user stats page: /u/{handle}/stats/
*/
import Base from '../../../../layouts/Base.astro';
import StatsView from '../../../../components/StatsView.svelte';
import { readShardHandles } from '../../../../lib/manifest';
export function getStaticPaths() {
return readShardHandles().map(({ handle }) => ({
params: { handle },
props: { handle },
}));
}
const { handle } = Astro.props as { handle: string };
const base = import.meta.env.BASE_URL;
const indexUrl = `${base}data/${handle}/_merged/index.json`;
---
<Base title={`@${handle} Stats — BincioActivity`}>
<div class="max-w-5xl mx-auto px-4 pt-6 pb-2">
<h1 class="text-2xl font-bold text-white mb-0.5">@{handle}</h1>
<nav class="flex gap-4 mt-1 mb-6">
<a href={`${base}u/${handle}/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Feed</a>
<a href={`${base}u/${handle}/stats/`} class="text-sm text-[--accent]">Stats</a>
<a href={`${base}u/${handle}/athlete/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Athlete</a>
</nav>
</div>
<StatsView {indexUrl} client:only="svelte" />
</Base>