fix: close all bincio-auth migration holes

Pages (register, reset-password, invites) now redirect to bincio.org
like login already did. Admin user-state ops (reset-password-code,
suspend, unsuspend, delete account) are proxied to bincio-auth via
httpx so they write to the correct DB. Adds BINCIO_AUTH_API env var.
This commit is contained in:
Davide Scaini
2026-06-03 09:36:20 +02:00
parent 75f7fa8810
commit 0e5044eb06
6 changed files with 37 additions and 365 deletions
+4 -1
View File
@@ -23,11 +23,12 @@ console = Console()
@click.option("--dem-url", default=None, envvar="DEM_URL", help="Base URL of an Open-Elevation-compatible API (default: https://api.open-elevation.com).") @click.option("--dem-url", default=None, envvar="DEM_URL", help="Base URL of an Open-Elevation-compatible API (default: https://api.open-elevation.com).")
@click.option("--sync-secret", default=None, envvar="BINCIO_SYNC_SECRET", help="Shared secret for POST /api/internal/rebuild (used by the sync-strava systemd timer).") @click.option("--sync-secret", default=None, envvar="BINCIO_SYNC_SECRET", help="Shared secret for POST /api/internal/rebuild (used by the sync-strava systemd timer).")
@click.option("--jwt-secret", default=None, envvar="BINCIO_AUTH_JWT_SECRET", help="Shared JWT secret from bincio-auth. When set, validates JWTs locally instead of DB session lookup.") @click.option("--jwt-secret", default=None, envvar="BINCIO_AUTH_JWT_SECRET", help="Shared JWT secret from bincio-auth. When set, validates JWTs locally instead of DB session lookup.")
@click.option("--auth-api", default=None, envvar="BINCIO_AUTH_API", help="Internal URL of the bincio-auth API (e.g. http://127.0.0.1:4040). When set, admin user-state operations are proxied to bincio-auth.")
def serve(data_dir: str, site_dir: str | None, host: str, port: int, def serve(data_dir: str, site_dir: str | None, host: str, port: int,
strava_client_id: str | None, strava_client_secret: str | None, strava_client_id: str | None, strava_client_secret: str | None,
max_users: int | None, public_url: str | None, max_users: int | None, public_url: str | None,
webroot: str | None, dem_url: str | None, webroot: str | None, dem_url: str | None,
sync_secret: str | None, jwt_secret: str | None) -> None: sync_secret: str | None, jwt_secret: str | None, auth_api: str | None) -> None:
"""Start the bincio multi-user application server. """Start the bincio multi-user application server.
Handles auth, user management, and write operations. Handles auth, user management, and write operations.
@@ -69,6 +70,8 @@ def serve(data_dir: str, site_dir: str | None, host: str, port: int,
deps.sync_secret = sync_secret deps.sync_secret = sync_secret
if jwt_secret: if jwt_secret:
deps.jwt_secret = jwt_secret deps.jwt_secret = jwt_secret
if auth_api:
deps.auth_api = auth_api.rstrip("/")
db = open_db(dd) db = open_db(dd)
current_limit = get_setting(db, "max_users") current_limit = get_setting(db, "max_users")
+1
View File
@@ -37,6 +37,7 @@ public_url: str = ""
dem_url: str = "https://api.open-elevation.com" dem_url: str = "https://api.open-elevation.com"
sync_secret: str = "" sync_secret: str = ""
jwt_secret: str = "" # when set, validates JWTs from bincio-auth instead of DB session lookup jwt_secret: str = "" # when set, validates JWTs from bincio-auth instead of DB session lookup
auth_api: str = "" # when set, proxies user-state admin ops to bincio-auth (e.g. http://127.0.0.1:4040)
_db = None _db = None
_strava_sync_running = False _strava_sync_running = False
_strava_sync_lock = threading.Lock() _strava_sync_lock = threading.Lock()
+20 -38
View File
@@ -9,10 +9,22 @@ import threading
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import httpx
from fastapi import APIRouter, Cookie, HTTPException, Request from fastapi import APIRouter, Cookie, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from bincio.serve import deps, tasks from bincio.serve import deps, tasks
async def _auth_proxy(method: str, path: str, cookie: str | None) -> JSONResponse:
"""Forward a user-state admin request to bincio-auth and relay the response."""
if not deps.auth_api:
raise HTTPException(503, "User management is handled by bincio-auth but BINCIO_AUTH_API is not configured.")
url = f"{deps.auth_api}{path}"
cookies = {"bincio_session": cookie} if cookie else {}
async with httpx.AsyncClient() as client:
r = await client.request(method, url, cookies=cookies)
return JSONResponse(r.json(), status_code=r.status_code)
from bincio.serve.models import ResetPasswordCodeResponse from bincio.serve.models import ResetPasswordCodeResponse
from bincio.serve.db import ( from bincio.serve.db import (
User, User,
@@ -152,14 +164,8 @@ async def admin_reset_password_code(
handle: str, handle: str,
bincio_session: str | None = Cookie(default=None), bincio_session: str | None = Cookie(default=None),
) -> JSONResponse: ) -> JSONResponse:
"""Generate a one-time password reset code for a user. Admin only.""" """Generate a one-time password reset code for a user. Proxied to bincio-auth."""
from bincio.serve.db import create_reset_code return await _auth_proxy("POST", f"/api/admin/users/{handle}/reset-password-code", bincio_session)
admin = deps._require_admin(bincio_session)
db = deps._get_db()
if not get_user(db, handle):
raise HTTPException(404, f"User '{handle}' not found")
code = create_reset_code(db, handle, admin.handle)
return JSONResponse({"ok": True, "code": code, "expires_in_hours": 24})
@router.post("/api/admin/users/{handle}/suspend") @router.post("/api/admin/users/{handle}/suspend")
@@ -167,18 +173,8 @@ async def admin_suspend(
handle: str, handle: str,
bincio_session: str | None = Cookie(default=None), bincio_session: str | None = Cookie(default=None),
) -> JSONResponse: ) -> JSONResponse:
"""Suspend a user account. Blocks login and invalidates existing sessions. Admin only.""" """Suspend a user account. Proxied to bincio-auth."""
from bincio.serve.db import set_suspended, purge_expired_sessions return await _auth_proxy("POST", f"/api/admin/users/{handle}/suspend", bincio_session)
admin = deps._require_admin(bincio_session)
if handle == admin.handle:
raise HTTPException(400, "Cannot suspend yourself")
db = deps._get_db()
if not get_user(db, handle):
raise HTTPException(404, "User not found")
set_suspended(db, handle, True)
db.execute("DELETE FROM sessions WHERE handle = ?", (handle,))
db.commit()
return JSONResponse({"status": "suspended", "handle": handle})
@router.post("/api/admin/users/{handle}/unsuspend") @router.post("/api/admin/users/{handle}/unsuspend")
@@ -186,14 +182,8 @@ async def admin_unsuspend(
handle: str, handle: str,
bincio_session: str | None = Cookie(default=None), bincio_session: str | None = Cookie(default=None),
) -> JSONResponse: ) -> JSONResponse:
"""Re-enable a suspended user account. Admin only.""" """Re-enable a suspended user account. Proxied to bincio-auth."""
from bincio.serve.db import set_suspended return await _auth_proxy("POST", f"/api/admin/users/{handle}/unsuspend", bincio_session)
deps._require_admin(bincio_session)
db = deps._get_db()
if not get_user(db, handle):
raise HTTPException(404, "User not found")
set_suspended(db, handle, False)
return JSONResponse({"status": "unsuspended", "handle": handle})
@router.delete("/api/admin/users/{handle}/account") @router.delete("/api/admin/users/{handle}/account")
@@ -201,16 +191,8 @@ async def admin_delete_account(
handle: str, handle: str,
bincio_session: str | None = Cookie(default=None), bincio_session: str | None = Cookie(default=None),
) -> JSONResponse: ) -> JSONResponse:
"""Delete a user account from the database. Data directory is NOT removed. Admin only.""" """Delete a user account. Proxied to bincio-auth."""
from bincio.serve.db import delete_user as _delete_user return await _auth_proxy("DELETE", f"/api/admin/users/{handle}/account", bincio_session)
admin = deps._require_admin(bincio_session)
if handle == admin.handle:
raise HTTPException(400, "Cannot delete your own account")
db = deps._get_db()
if not get_user(db, handle):
raise HTTPException(404, "User not found")
_delete_user(db, handle)
return JSONResponse({"status": "deleted", "handle": handle})
@router.post("/api/admin/users/{handle}/rebuild") @router.post("/api/admin/users/{handle}/rebuild")
+4 -156
View File
@@ -1,158 +1,6 @@
--- ---
import Base from '../../layouts/Base.astro'; const authUrl = import.meta.env.PUBLIC_AUTH_URL ?? '';
const target = authUrl ? authUrl + '/invites/' : '/';
--- ---
<Base title="Invites — Bincio"> <meta http-equiv="refresh" content={`0;url=${target}`} />
<div class="max-w-lg mx-auto mt-12 px-4"> <script define:vars={{ target }}>window.location.replace(target);</script>
<div class="flex items-center justify-between mb-4">
<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>
<!-- Activity toggle: only shown for users who have activity_access -->
<div id="activity-toggle-row" style="display:none" class="mb-6">
<label class="flex items-center gap-2 cursor-pointer select-none group">
<input id="grants-activity" type="checkbox"
class="w-4 h-4 rounded accent-[--accent] cursor-pointer" />
<span class="text-sm text-zinc-400 group-hover:text-zinc-300 transition-colors">
Include activity access
</span>
<span class="text-xs text-zinc-600">(wiki + activity)</span>
</label>
</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')!;
const toggleRow = document.getElementById('activity-toggle-row') as HTMLElement;
const grantsActivityChk = document.getElementById('grants-activity') as HTMLInputElement;
type Invite = {
code: string;
used: boolean;
used_by: string | null;
created_at: number;
grants_activity: boolean;
};
function renderInvite(inv: Invite) {
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}`;
const badge = inv.grants_activity
? `<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-400 border border-zinc-700">wiki + activity</span>`
: `<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-600 border border-zinc-700">wiki only</span>`;
li.innerHTML = `
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-mono text-lg tracking-widest ${inv.used ? 'text-zinc-600 line-through' : 'text-white'}">${inv.code}</span>
${badge}
</div>
<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 ml-3 shrink-0 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;
}
function fallbackCopy(text: string, done: () => void) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0';
document.body.appendChild(ta);
ta.focus();
ta.select();
try { document.execCommand('copy'); done(); } catch (_) {}
document.body.removeChild(ta);
}
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: Invite[] = 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));
listEl.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', () => {
const text = (btn as HTMLElement).dataset.link ?? '';
const done = () => {
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = 'Copy link'; }, 2000);
};
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(done).catch(() => fallbackCopy(text, done));
} else {
fallbackCopy(text, done);
}
});
});
} catch (e: any) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
}
}
// Show activity toggle only for users who have activity access
fetch('/api/me', { credentials: 'include' })
.then(async r => {
if (!r.ok) return;
const user = await r.json();
if (user.activity_access) toggleRow.style.display = '';
})
.catch(() => {});
document.getElementById('gen-btn')?.addEventListener('click', async () => {
errEl.classList.add('hidden');
const grantsActivity = grantsActivityChk?.checked ?? false;
try {
const r = await fetch('/api/invites', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grants_activity: grantsActivity }),
});
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>
+4 -85
View File
@@ -1,87 +1,6 @@
--- ---
import Base from '../../layouts/Base.astro'; const authUrl = import.meta.env.PUBLIC_AUTH_URL ?? '';
const target = authUrl ? authUrl + '/register/' : '/';
--- ---
<Base title="Create account — BincioActivity" public={true}> <meta http-equiv="refresh" content={`0;url=${target}`} />
<div class="max-w-sm mx-auto mt-16 px-4"> <script define:vars={{ target }}>window.location.replace(target);</script>
<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>
+4 -85
View File
@@ -1,87 +1,6 @@
--- ---
import Base from '../../layouts/Base.astro'; const authUrl = import.meta.env.PUBLIC_AUTH_URL ?? '';
const target = authUrl ? authUrl + '/reset-password/' : '/';
--- ---
<Base title="Reset password — BincioActivity" public={true}> <meta http-equiv="refresh" content={`0;url=${target}`} />
<div class="max-w-sm mx-auto mt-16 px-4"> <script define:vars={{ target }}>window.location.replace(target);</script>
<h1 class="text-2xl font-bold text-white mb-2 text-center">Reset password</h1>
<p class="text-zinc-500 text-sm text-center mb-2">Enter the reset code you received from the admin.</p>
<p class="text-zinc-600 text-xs text-center mb-6">Don't have a code? Contact the instance admin — they can generate one for you from the admin panel. Codes expire after 24 hours.</p>
<form id="reset-form" class="space-y-4">
<div>
<label class="block text-sm text-zinc-400 mb-1" for="code">Reset 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="your handle" required />
</div>
<div>
<label class="block text-sm text-zinc-400 mb-1" for="password">New 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="reset-error" class="text-red-400 text-sm hidden"></p>
<p id="reset-ok" class="text-green-400 text-sm hidden">Password updated. <a href="/login/" class="underline">Sign in</a></p>
<button type="submit"
class="w-full py-2 rounded-lg bg-[--accent] hover:opacity-90 text-white font-medium transition-opacity">
Set new password
</button>
</form>
<p class="text-center text-zinc-500 text-sm mt-6">
<a href="/login/" class="text-[--accent] hover:underline">Back to sign in</a>
</p>
</div>
</Base>
<script>
// Pre-fill code and handle from query params if provided
const params = new URLSearchParams(window.location.search);
const codeParam = params.get('code');
const handleParam = params.get('handle');
if (codeParam) (document.getElementById('code') as HTMLInputElement).value = codeParam.toUpperCase();
if (handleParam) (document.getElementById('handle') as HTMLInputElement).value = handleParam;
document.getElementById('reset-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const errEl = document.getElementById('reset-error')!;
const okEl = document.getElementById('reset-ok')!;
errEl.classList.add('hidden');
okEl.classList.add('hidden');
const body = {
code: (form.querySelector('#code') as HTMLInputElement).value.trim().toUpperCase(),
handle: (form.querySelector('#handle') as HTMLInputElement).value.trim().toLowerCase(),
password: (form.querySelector('#password') as HTMLInputElement).value,
};
try {
const r = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
errEl.textContent = d.detail ?? 'Reset failed';
errEl.classList.remove('hidden');
return;
}
okEl.classList.remove('hidden');
(e.target as HTMLFormElement).querySelectorAll('input, button').forEach(
el => (el as HTMLInputElement).disabled = true
);
} catch {
errEl.textContent = 'Could not reach server';
errEl.classList.remove('hidden');
}
});
</script>