towards multi-user
This commit is contained in:
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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")
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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",
|
||||
))
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 @@
|
||||
<!-- header -->
|
||||
<div class="flex items-start justify-between gap-2 mb-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-zinc-500 mb-0.5">{formatDate(a.started_at)}</p>
|
||||
<p class="text-xs text-zinc-500 mb-0.5">
|
||||
{formatDate(a.started_at)}{#if a.handle} · <a href={`${import.meta.env.BASE_URL}${a.handle}/`} class="hover:text-zinc-300 transition-colors" on:click|stopPropagation>@{a.handle}</a>{/if}
|
||||
</p>
|
||||
<h3 class="font-semibold text-white truncate group-hover:text-[--accent] transition-colors">
|
||||
{a.title}
|
||||
</h3>
|
||||
|
||||
@@ -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 */ }
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
@@ -28,6 +48,15 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Auth wall: redirect to /login/ on private instances when not authenticated -->
|
||||
{instancePrivate && !isPublicPage && (
|
||||
<script is:inline>
|
||||
fetch('/api/me', { credentials: 'include' })
|
||||
.then(r => { if (r.status === 401 || r.status === 404) window.location.replace('/login/'); })
|
||||
.catch(() => {});
|
||||
</script>
|
||||
)}
|
||||
|
||||
<style is:global>
|
||||
/* ── Theme tokens ─────────────────────────────────────────────────────── */
|
||||
:root, [data-theme="dark"] {
|
||||
|
||||
@@ -58,28 +58,76 @@ function emptyIndex(): BASIndex {
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load the activity index, merging the server's copy with any locally-stored
|
||||
* activities. Local entries override server entries with the same ID.
|
||||
* Resolve shards from a BASIndex into a flat activity list.
|
||||
*
|
||||
* Handles two shard types transparently:
|
||||
* - handle shards: multi-user manifest (url = "{handle}/index.json")
|
||||
* - year shards: per-user pagination (url = "index-2025.json")
|
||||
*
|
||||
* Shard URLs are resolved relative to the index URL that declared them.
|
||||
* All shard fetches run concurrently. Errors are silently skipped so a
|
||||
* single unavailable shard doesn't break the whole feed.
|
||||
*/
|
||||
export async function loadIndex(baseUrl: string): Promise<BASIndex> {
|
||||
async function resolveShards(
|
||||
index: BASIndex,
|
||||
indexUrl: string,
|
||||
): Promise<ActivitySummary[]> {
|
||||
if (!index.shards?.length) return index.activities ?? [];
|
||||
|
||||
const base = indexUrl.substring(0, indexUrl.lastIndexOf('/') + 1);
|
||||
|
||||
const shardResults = await Promise.allSettled(
|
||||
index.shards.map(async shard => {
|
||||
const url = shard.url.startsWith('http') ? shard.url : `${base}${shard.url}`;
|
||||
const sub = await fetchJSON<BASIndex>(url);
|
||||
// Recursively resolve nested shards (e.g. user shard that itself paginates)
|
||||
const activities = await resolveShards(sub, url);
|
||||
// Tag each activity with the handle declared in the shard entry
|
||||
if (shard.handle) {
|
||||
return activities.map(a => ({ ...a, handle: shard.handle }));
|
||||
}
|
||||
return activities;
|
||||
}),
|
||||
);
|
||||
|
||||
const own = index.activities ?? [];
|
||||
const fromShards = shardResults.flatMap(r => r.status === 'fulfilled' ? r.value : []);
|
||||
return [...own, ...fromShards];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the activity index, resolving any shards (multi-user or pagination),
|
||||
* then merging with locally-stored activities from IndexedDB.
|
||||
*
|
||||
* Single-user indexes with no shards work exactly as before — zero overhead.
|
||||
*
|
||||
* @param baseUrl Site base URL (used for IDB local activities)
|
||||
* @param indexUrl Full URL of the index to load (defaults to baseUrl + data/index.json)
|
||||
*/
|
||||
export async function loadIndex(baseUrl: string, indexUrl?: string): Promise<BASIndex> {
|
||||
indexUrl = indexUrl ?? `${baseUrl}data/index.json`;
|
||||
|
||||
const [serverResult, localResult] = await Promise.allSettled([
|
||||
fetchJSON<BASIndex>(`${baseUrl}data/index.json`),
|
||||
fetchJSON<BASIndex>(indexUrl),
|
||||
listLocalActivities(),
|
||||
]);
|
||||
|
||||
const server = serverResult.status === 'fulfilled' ? serverResult.value : null;
|
||||
const local = localResult.status === 'fulfilled' ? localResult.value : [];
|
||||
|
||||
if (local.length === 0) return server ?? emptyIndex();
|
||||
if (!server) return { ...emptyIndex(), activities: local as ActivitySummary[] };
|
||||
const serverActivities = server
|
||||
? await resolveShards(server, indexUrl)
|
||||
: [];
|
||||
|
||||
if (local.length === 0 && !server) return emptyIndex();
|
||||
|
||||
// Local overrides server for the same ID; new local entries are appended
|
||||
const merged = new Map<string, ActivitySummary>();
|
||||
for (const a of server.activities ?? []) merged.set(a.id, a);
|
||||
for (const a of serverActivities) merged.set(a.id, a);
|
||||
for (const a of local as ActivitySummary[]) merged.set(a.id, a);
|
||||
|
||||
return {
|
||||
...server,
|
||||
...(server ?? emptyIndex()),
|
||||
activities: [...merged.values()].sort(
|
||||
(a, b) => (b.started_at ?? '').localeCompare(a.started_at ?? ''),
|
||||
),
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface ActivitySummary {
|
||||
track_url: string | null;
|
||||
/** ~20 [lat, lon] pairs for card thumbnail — no separate fetch needed. */
|
||||
preview_coords: [number, number][] | null;
|
||||
/** Set on multi-user instances — the handle of the activity owner. */
|
||||
handle?: string;
|
||||
}
|
||||
|
||||
export interface AthleteZones {
|
||||
@@ -81,9 +83,12 @@ export interface AthleteZones {
|
||||
|
||||
export interface BASIndex {
|
||||
bas_version: string;
|
||||
owner: { handle: string; display_name: string; avatar_url: string | null; athlete?: AthleteZones };
|
||||
owner?: { handle: string; display_name: string; avatar_url?: string | null; athlete?: AthleteZones };
|
||||
instance?: { name?: string; url?: string };
|
||||
generated_at: string;
|
||||
shards: Array<{ year: number; url: string; count: number }>;
|
||||
// Shards can be user shards (multi-user manifest) or year shards (pagination).
|
||||
// handle present → user shard; year present → pagination shard.
|
||||
shards: Array<{ url: string; handle?: string; year?: number; count?: number }>;
|
||||
activities: ActivitySummary[];
|
||||
}
|
||||
|
||||
|
||||
@@ -9,18 +9,38 @@ export async function getStaticPaths() {
|
||||
try {
|
||||
const candidates = [
|
||||
process.env.BINCIO_DATA_DIR,
|
||||
resolve(process.cwd(), 'public', 'data'), // symlinked by `bincio render`
|
||||
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; } })!;
|
||||
const raw = readFileSync(join(dataDir, 'index.json'), 'utf-8');
|
||||
const index: BASIndex = JSON.parse(raw);
|
||||
const dataDir = candidates.find(d => {
|
||||
try { readFileSync(join(d, 'index.json')); return true; } catch { return false; }
|
||||
})!;
|
||||
|
||||
return index.activities
|
||||
const root: BASIndex = JSON.parse(readFileSync(join(dataDir, 'index.json'), 'utf-8'));
|
||||
|
||||
// Collect activities from root (single-user) or walk shards (multi-user)
|
||||
function readActivities(indexPath: string): ActivitySummary[] {
|
||||
try {
|
||||
const idx: BASIndex = JSON.parse(readFileSync(indexPath, 'utf-8'));
|
||||
const own = idx.activities ?? [];
|
||||
const fromShards = (idx.shards ?? []).flatMap(s => {
|
||||
const shardPath = join(dataDir, s.url);
|
||||
return readActivities(shardPath);
|
||||
});
|
||||
return [...own, ...fromShards];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const activities = readActivities(join(dataDir, 'index.json'));
|
||||
const athlete = root.owner?.athlete ?? null;
|
||||
|
||||
return activities
|
||||
.filter(a => a.privacy !== 'private' && a.id)
|
||||
.map(a => ({
|
||||
params: { id: a.id },
|
||||
props: { activity: a, athlete: index.owner.athlete ?? null },
|
||||
props: { activity: a, athlete },
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
---
|
||||
<Base title="Invites — BincioActivity">
|
||||
<div class="max-w-lg mx-auto mt-12 px-4">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Your invites</h1>
|
||||
<button id="gen-btn"
|
||||
class="px-4 py-2 rounded-lg bg-[--accent] hover:opacity-90 text-white text-sm font-medium transition-opacity">
|
||||
Generate invite
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p id="invite-error" class="text-red-400 text-sm mb-4 hidden"></p>
|
||||
|
||||
<ul id="invite-list" class="space-y-3">
|
||||
<li class="text-zinc-500 text-sm">Loading…</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<script>
|
||||
const listEl = document.getElementById('invite-list')!;
|
||||
const errEl = document.getElementById('invite-error')!;
|
||||
|
||||
function renderInvite(inv: { code: string; used: boolean; used_by: string | null; created_at: number }) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'flex items-center justify-between rounded-xl bg-zinc-900 border border-zinc-800 px-4 py-3';
|
||||
|
||||
const date = new Date(inv.created_at * 1000).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
const registerUrl = `${window.location.origin}/register/?code=${inv.code}`;
|
||||
|
||||
li.innerHTML = `
|
||||
<div>
|
||||
<span class="font-mono text-lg tracking-widest ${inv.used ? 'text-zinc-600 line-through' : 'text-white'}">${inv.code}</span>
|
||||
<p class="text-xs text-zinc-500 mt-0.5">
|
||||
${inv.used ? `Used by @${inv.used_by}` : `Created ${date}`}
|
||||
</p>
|
||||
</div>
|
||||
${!inv.used ? `
|
||||
<button data-link="${registerUrl}"
|
||||
class="copy-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors">
|
||||
Copy link
|
||||
</button>
|
||||
` : ''}
|
||||
`;
|
||||
return li;
|
||||
}
|
||||
|
||||
async function loadInvites() {
|
||||
try {
|
||||
const r = await fetch('/api/invites', { credentials: 'include' });
|
||||
if (r.status === 401) {
|
||||
window.location.href = `/login/?next=${encodeURIComponent(window.location.pathname)}`;
|
||||
return;
|
||||
}
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const invites = await r.json();
|
||||
listEl.innerHTML = '';
|
||||
if (invites.length === 0) {
|
||||
listEl.innerHTML = '<li class="text-zinc-500 text-sm">No invites yet.</li>';
|
||||
return;
|
||||
}
|
||||
for (const inv of invites) listEl.appendChild(renderInvite(inv));
|
||||
|
||||
// Copy link buttons
|
||||
listEl.querySelectorAll('.copy-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText((btn as HTMLElement).dataset.link ?? '');
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => { btn.textContent = 'Copy link'; }, 2000);
|
||||
});
|
||||
});
|
||||
} catch (e: any) {
|
||||
errEl.textContent = e.message;
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('gen-btn')?.addEventListener('click', async () => {
|
||||
errEl.classList.add('hidden');
|
||||
try {
|
||||
const r = await fetch('/api/invites', { method: 'POST', credentials: 'include' });
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
errEl.textContent = d.detail ?? 'Could not generate invite';
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
await loadInvites();
|
||||
} catch (e: any) {
|
||||
errEl.textContent = e.message;
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
loadInvites();
|
||||
</script>
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
|
||||
---
|
||||
<Base title="Login — BincioActivity" public={true}>
|
||||
<div class="max-w-sm mx-auto mt-16 px-4">
|
||||
<h1 class="text-2xl font-bold text-white mb-6 text-center">Sign in</h1>
|
||||
|
||||
<form id="login-form" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-zinc-400 mb-1" for="handle">Handle</label>
|
||||
<input id="handle" name="handle" type="text" autocomplete="username"
|
||||
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-[--accent]"
|
||||
placeholder="your handle" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-zinc-400 mb-1" for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white focus:outline-none focus:border-[--accent]"
|
||||
required />
|
||||
</div>
|
||||
<p id="login-error" class="text-red-400 text-sm hidden"></p>
|
||||
<button type="submit"
|
||||
class="w-full py-2 rounded-lg bg-[--accent] hover:opacity-90 text-white font-medium transition-opacity">
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{editUrl && (
|
||||
<p class="text-center text-zinc-500 text-sm mt-6">
|
||||
Have an invite? <a href="/register/" class="text-[--accent] hover:underline">Create account</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<script>
|
||||
const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? (document.getElementById('login-form')?.dataset.editUrl ?? '');
|
||||
|
||||
document.getElementById('login-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target as HTMLFormElement;
|
||||
const handle = (form.querySelector('#handle') as HTMLInputElement).value.trim();
|
||||
const password = (form.querySelector('#password') as HTMLInputElement).value;
|
||||
const errEl = document.getElementById('login-error')!;
|
||||
errEl.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ handle, password }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
errEl.textContent = d.detail ?? 'Invalid credentials';
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
const next = new URLSearchParams(window.location.search).get('next') ?? '/';
|
||||
window.location.href = next;
|
||||
} catch {
|
||||
errEl.textContent = 'Could not reach server';
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
---
|
||||
<Base title="Create account — BincioActivity" public={true}>
|
||||
<div class="max-w-sm mx-auto mt-16 px-4">
|
||||
<h1 class="text-2xl font-bold text-white mb-6 text-center">Create account</h1>
|
||||
|
||||
<form id="register-form" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-zinc-400 mb-1" for="code">Invite code</label>
|
||||
<input id="code" name="code" type="text" autocomplete="off"
|
||||
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white font-mono uppercase tracking-widest placeholder-zinc-500 focus:outline-none focus:border-[--accent]"
|
||||
placeholder="XXXXXXXX" maxlength="8" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-zinc-400 mb-1" for="handle">Handle</label>
|
||||
<input id="handle" name="handle" type="text" autocomplete="username"
|
||||
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-[--accent]"
|
||||
placeholder="lowercase, letters and numbers" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-zinc-400 mb-1" for="display_name">Display name <span class="text-zinc-600">(optional)</span></label>
|
||||
<input id="display_name" name="display_name" type="text"
|
||||
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white placeholder-zinc-500 focus:outline-none focus:border-[--accent]"
|
||||
placeholder="Your Name" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-zinc-400 mb-1" for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="new-password"
|
||||
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white focus:outline-none focus:border-[--accent]"
|
||||
minlength="8" required />
|
||||
<p class="text-zinc-600 text-xs mt-1">At least 8 characters</p>
|
||||
</div>
|
||||
<p id="reg-error" class="text-red-400 text-sm hidden"></p>
|
||||
<button type="submit"
|
||||
class="w-full py-2 rounded-lg bg-[--accent] hover:opacity-90 text-white font-medium transition-opacity">
|
||||
Create account
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-zinc-500 text-sm mt-6">
|
||||
Already have an account? <a href="/login/" class="text-[--accent] hover:underline">Sign in</a>
|
||||
</p>
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<script>
|
||||
// Pre-fill invite code from query param
|
||||
const code = new URLSearchParams(window.location.search).get('code');
|
||||
if (code) {
|
||||
const input = document.getElementById('code') as HTMLInputElement;
|
||||
if (input) input.value = code.toUpperCase();
|
||||
}
|
||||
|
||||
document.getElementById('register-form')?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target as HTMLFormElement;
|
||||
const errEl = document.getElementById('reg-error')!;
|
||||
errEl.classList.add('hidden');
|
||||
|
||||
const body = {
|
||||
code: (form.querySelector('#code') as HTMLInputElement).value.trim().toUpperCase(),
|
||||
handle: (form.querySelector('#handle') as HTMLInputElement).value.trim().toLowerCase(),
|
||||
display_name: (form.querySelector('#display_name') as HTMLInputElement).value.trim(),
|
||||
password: (form.querySelector('#password') as HTMLInputElement).value,
|
||||
};
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/register', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
errEl.textContent = d.detail ?? 'Registration failed';
|
||||
errEl.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
window.location.href = '/';
|
||||
} catch {
|
||||
errEl.textContent = 'Could not reach server';
|
||||
errEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
/**
|
||||
* 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>
|
||||
Reference in New Issue
Block a user