diff --git a/deploy.sh b/deploy.sh index 1cab95e..cc937bc 100755 --- a/deploy.sh +++ b/deploy.sh @@ -2,6 +2,6 @@ set -e VPS=root@95.216.55.151 -VITE_ACTIVITY_URL=https://activity.bincio.org npm run build +VITE_ACTIVITY_URL=https://activity.bincio.org VITE_PLANNER_API_URL= npm run build rsync -az --delete dist/ "$VPS:/var/www/planner/" echo "Deployed to planner.bincio.org" diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..7339623 --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.111 +uvicorn[standard]>=0.29 +pydantic>=2.0 diff --git a/server/server.py b/server/server.py new file mode 100644 index 0000000..e7a63f9 --- /dev/null +++ b/server/server.py @@ -0,0 +1,197 @@ +"""BincioPlanner API — save/load route plans. + +Auth: reads bincio_session cookie, validates against shared instance.db (same strategy as bincio_wiki). +Storage: JSON files at $PLANS_DIR/{handle}/{plan_id}.json + +Run: uvicorn server:app --host 127.0.0.1 --port 8002 +Env vars: + SHARED_DB_PATH path to bincio_activity instance.db (default: /var/bincio/data/instance.db) + PLANS_DIR root dir for plan JSON files (default: /var/bincio_planner) +""" +from __future__ import annotations + +import json +import os +import re +import secrets +import sqlite3 +import time +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from fastapi import Cookie, Depends, FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +_SHARED_DB_PATH = Path(os.environ.get("SHARED_DB_PATH", "/var/bincio/data/instance.db")) +_PLANS_DIR = Path(os.environ.get("PLANS_DIR", "/var/bincio_planner")) +_SESSION_COOKIE = "bincio_session" + +_SAFE_ID = re.compile(r"^[A-Za-z0-9_-]{1,32}$") + + +# ── Auth ─────────────────────────────────────────────────────────────────────── + +@dataclass +class User: + handle: str + display_name: str + + +@contextmanager +def _db(): + if not _SHARED_DB_PATH.exists(): + raise HTTPException(503, f"Shared DB not found at {_SHARED_DB_PATH}. " + "Set SHARED_DB_PATH or run bincio_activity first.") + con = sqlite3.connect(_SHARED_DB_PATH, check_same_thread=False) + con.row_factory = sqlite3.Row + con.execute("PRAGMA journal_mode=WAL") + try: + yield con + finally: + con.close() + + +def _get_session_user(token: str) -> Optional[User]: + try: + with _db() as con: + row = con.execute( + "SELECT s.handle, s.expires_at, u.display_name, u.activity_access, u.suspended " + "FROM sessions s JOIN users u ON s.handle = u.handle " + "WHERE s.token = ?", + (token,), + ).fetchone() + except HTTPException: + raise + except Exception: + return None + if not row: + return None + if row["expires_at"] < int(time.time()): + return None + if not row["activity_access"]: + return None + if row["suspended"]: + return None + return User(handle=row["handle"], display_name=row["display_name"]) + + +async def require_auth(bincio_session: Optional[str] = Cookie(default=None)) -> User: + if not bincio_session: + raise HTTPException(401, "Authentication required") + user = _get_session_user(bincio_session) + if not user: + raise HTTPException(401, "Authentication required") + return user + + +# ── App ──────────────────────────────────────────────────────────────────────── + +app = FastAPI(title="BincioPlanner API", docs_url=None, redoc_url=None) +app.add_middleware(GZipMiddleware, minimum_size=1024) +app.add_middleware( + CORSMiddleware, + allow_origin_regex=r"https?://localhost(:\d+)?", + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE"], + allow_headers=["Content-Type"], +) + + +# ── Storage helpers ──────────────────────────────────────────────────────────── + +def _user_dir(handle: str) -> Path: + d = _PLANS_DIR / handle + d.mkdir(parents=True, exist_ok=True) + return d + + +def _plan_path(handle: str, plan_id: str) -> Path: + if not _SAFE_ID.match(plan_id): + raise HTTPException(400, "Invalid plan id") + return _PLANS_DIR / handle / f"{plan_id}.json" + + +# ── Endpoints ────────────────────────────────────────────────────────────────── + +class PlanBody(BaseModel): + name: str + waypoints: list[dict] + profile: str + geojson: dict | None = None + + +@app.get("/api/plans") +async def list_plans(user: User = Depends(require_auth)) -> JSONResponse: + d = _PLANS_DIR / user.handle + if not d.exists(): + return JSONResponse({"plans": []}) + plans = [] + for p in sorted(d.glob("*.json"), key=lambda f: f.stat().st_mtime, reverse=True): + try: + data = json.loads(p.read_text(encoding="utf-8")) + plans.append({k: v for k, v in data.items() if k != "geojson"}) + except Exception: + pass + return JSONResponse({"plans": plans}) + + +@app.post("/api/plans") +async def create_plan(body: PlanBody, user: User = Depends(require_auth)) -> JSONResponse: + plan_id = secrets.token_urlsafe(12) + now = int(time.time()) + data: dict = { + "id": plan_id, + "name": body.name.strip() or "Unnamed route", + "waypoints": body.waypoints, + "profile": body.profile, + "created_at": now, + "updated_at": now, + } + if body.geojson is not None: + data["geojson"] = body.geojson + _user_dir(user.handle).joinpath(f"{plan_id}.json").write_text( + json.dumps(data), encoding="utf-8" + ) + return JSONResponse({"id": plan_id, "created_at": now}) + + +@app.get("/api/plans/{plan_id}") +async def get_plan(plan_id: str, user: User = Depends(require_auth)) -> JSONResponse: + p = _plan_path(user.handle, plan_id) + if not p.exists(): + raise HTTPException(404, "Plan not found") + return JSONResponse(json.loads(p.read_text(encoding="utf-8"))) + + +@app.put("/api/plans/{plan_id}") +async def update_plan( + plan_id: str, body: PlanBody, user: User = Depends(require_auth) +) -> JSONResponse: + p = _plan_path(user.handle, plan_id) + if not p.exists(): + raise HTTPException(404, "Plan not found") + data = json.loads(p.read_text(encoding="utf-8")) + data.update({ + "name": body.name.strip() or data.get("name", "Unnamed route"), + "waypoints": body.waypoints, + "profile": body.profile, + "updated_at": int(time.time()), + }) + if body.geojson is not None: + data["geojson"] = body.geojson + p.write_text(json.dumps(data), encoding="utf-8") + return JSONResponse({"id": plan_id, "updated": True}) + + +@app.delete("/api/plans/{plan_id}") +async def delete_plan(plan_id: str, user: User = Depends(require_auth)) -> JSONResponse: + p = _plan_path(user.handle, plan_id) + if not p.exists(): + raise HTTPException(404, "Plan not found") + p.unlink() + return JSONResponse({"id": plan_id, "deleted": True}) diff --git a/src/Planner.svelte b/src/Planner.svelte index 8f72b80..6c30854 100644 --- a/src/Planner.svelte +++ b/src/Planner.svelte @@ -5,14 +5,24 @@ let { activityUrl } = $props(); + const API = `${import.meta.env.VITE_PLANNER_API_URL ?? ''}/api`; + // ── State ────────────────────────────────────────────────────────────────── let mapEl; let map; - let waypoints = $state([]); // [{lng, lat, marker}] - let profile = $state('gravel'); - let route = $state(null); // GeoJSON Feature from Brouter - let loading = $state(false); - let error = $state(''); + let waypoints = $state([]); // [{lng, lat, marker}] + let profile = $state('gravel'); + let route = $state(null); // GeoJSON Feature from Brouter + let loading = $state(false); + let error = $state(''); + + // Plans + let plans = $state([]); + let plansLoading = $state(false); + let activePlanId = $state(null); // id of currently loaded plan (null = unsaved) + let savePanel = $state(false); // show save form + let saveName = $state(''); + let saving = $state(false); const PROFILES = [ { id: 'fastbike', label: 'Road' }, @@ -96,12 +106,14 @@ }); map.on('click', onMapClick); + loadPlans(); }); onDestroy(() => { if (map) map.remove(); }); // ── Waypoint management ──────────────────────────────────────────────────── function onMapClick(e) { + activePlanId = null; addWaypoint(e.lngLat.lng, e.lngLat.lat); } @@ -122,6 +134,7 @@ waypoints[i].lng = newLng; waypoints[i].lat = newLat; } + activePlanId = null; fetchRoute(); }); @@ -132,8 +145,8 @@ function removeWaypoint(i) { waypoints[i].marker.remove(); waypoints = waypoints.filter((_, idx) => idx !== i); - // renumber remaining markers waypoints.forEach((wp, idx) => { wp.marker.getElement().textContent = idx + 1; }); + activePlanId = null; fetchRoute(); } @@ -205,6 +218,69 @@ ${trkpts} a.click(); } + // ── Plans API ───────────────────────────────────────────────────────────── + async function loadPlans() { + plansLoading = true; + try { + const r = await fetch(`${API}/plans`, { credentials: 'include' }); + if (r.ok) plans = (await r.json()).plans ?? []; + } catch {} + plansLoading = false; + } + + async function savePlan() { + if (!saveName.trim() || waypoints.length < 2) return; + saving = true; + const body = { + name: saveName.trim(), + waypoints: waypoints.map(({ lng, lat }) => ({ lng, lat })), + profile, + geojson: route ?? undefined, + }; + try { + if (activePlanId) { + await fetch(`${API}/plans/${activePlanId}`, { + method: 'PUT', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } else { + const r = await fetch(`${API}/plans`, { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (r.ok) activePlanId = (await r.json()).id; + } + savePanel = false; + await loadPlans(); + } catch {} + saving = false; + } + + async function loadPlan(plan) { + clearAll(); + profile = plan.profile ?? 'gravel'; + activePlanId = plan.id; + saveName = plan.name; + // If the full plan (with geojson) is needed, fetch it; list endpoint omits geojson. + const r = await fetch(`${API}/plans/${plan.id}`, { credentials: 'include' }); + if (!r.ok) return; + const full = await r.json(); + for (const { lng, lat } of (full.waypoints ?? [])) addWaypoint(lng, lat); + if (full.geojson) { + route = full.geojson; + if (map.getSource('route')) map.getSource('route').setData(route); + } + } + + async function deletePlan(plan, e) { + e.stopPropagation(); + await fetch(`${API}/plans/${plan.id}`, { method: 'DELETE', credentials: 'include' }); + if (activePlanId === plan.id) activePlanId = null; + await loadPlans(); + } + // ── Helpers ──────────────────────────────────────────────────────────────── function emptyGeoJSON() { return { type: 'FeatureCollection', features: [] }; @@ -294,6 +370,54 @@ ${trkpts} {#if loading}

Routing…

{/if} {#if error}

{error}

{/if} + + + {#if route && waypoints.length >= 2} +
+

Save route

+ {#if savePanel} +
+ e.key === 'Enter' && savePlan()} + /> +
+ + +
+
+ {:else} + + {/if} +
+ {/if} + + +
+

My plans {#if plansLoading}(loading…){/if}

+ {#if plans.length === 0 && !plansLoading} +

No saved plans yet.

+ {:else} + + {/if} +
@@ -419,6 +543,45 @@ ${trkpts} .map-wrap { flex: 1; } .elevation-wrap { height: 140px; flex-shrink: 0; background: var(--bg-card); border-top: 1px solid var(--border); } + .save-form { display: flex; flex-direction: column; gap: 0.5rem; } + .save-input { + width: 100%; + padding: 0.375rem 0.5rem; + border-radius: 0.375rem; + border: 1px solid var(--border); + background: var(--bg-elevated); + color: var(--text-primary); + font-size: 0.8rem; + box-sizing: border-box; + } + .save-input:focus { outline: none; border-color: var(--accent); } + .full-width { width: 100%; } + + .plan-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.25rem; } + .plan-item { + display: flex; + align-items: center; + border-radius: 0.375rem; + border: 1px solid transparent; + position: relative; + } + .plan-item:hover { background: var(--bg-elevated); border-color: var(--border); } + .plan-item.active { background: var(--accent-dim); border-color: var(--accent); } + .plan-load-btn { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.1rem; + padding: 0.4rem 0.5rem; + background: none; + border: none; + text-align: left; + cursor: pointer; + min-width: 0; + } + .plan-name { font-size: 0.8rem; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .plan-meta { font-size: 0.7rem; color: var(--text-5); } + :global(.wp-marker) { width: 1.5rem; height: 1.5rem;