Compare commits

4 Commits

Author SHA1 Message Date
Jon
dab2685498 some changes in startup 2026-06-07 20:24:03 +01:00
Jon
2d9cb4581a Fix start.sh: use python3, drop --reload, cleaner startup output 2026-06-03 01:05:01 +01:00
Jon
c54ba02253 Fix template errors: Starlette new API + remove hx-headers escaping
- TemplateResponse now uses (request, name, context) signature for Starlette 0.36+
- Replace per-button hx-headers with global htmx:configRequest token injection in base.html
- Fix JS cookie regex to handle leading semicolons correctly

Tested: login, auth redirect, profile create/view/delete all return correct status codes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 01:03:19 +01:00
Jon
6afc464d53 Redirect unauthenticated browser requests to /login instead of returning 401 2026-06-03 00:55:48 +01:00
5 changed files with 47 additions and 24 deletions

View File

@@ -52,20 +52,27 @@ def _check_auth(request: Request) -> bool:
def _require_auth(request: Request) -> None: def _require_auth(request: Request) -> None:
if not _check_auth(request): if not _check_auth(request):
from fastapi.responses import RedirectResponse
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
def _redirect_if_unauth(request: Request):
if not _check_auth(request):
return RedirectResponse("/login", status_code=303)
return None
# ── Auth ────────────────────────────────────────────────────────────────────── # ── Auth ──────────────────────────────────────────────────────────────────────
@app.get("/login", response_class=HTMLResponse) @app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request): async def login_page(request: Request):
return TEMPLATES.TemplateResponse("login.html", {"request": request}) return TEMPLATES.TemplateResponse(request, "login.html")
@app.post("/login") @app.post("/login")
async def login(request: Request, token: str = Form(...)): async def login(request: Request, token: str = Form(...)):
if token != AUTH_TOKEN: if token != AUTH_TOKEN:
return TEMPLATES.TemplateResponse("login.html", {"request": request, "error": "Invalid token"}) return TEMPLATES.TemplateResponse(request, "login.html", {"error": "Invalid token"})
resp = RedirectResponse("/", status_code=303) resp = RedirectResponse("/", status_code=303)
resp.set_cookie("token", token, httponly=True, samesite="lax") resp.set_cookie("token", token, httponly=True, samesite="lax")
return resp return resp
@@ -82,13 +89,13 @@ async def logout():
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def index(request: Request): async def index(request: Request):
_require_auth(request) if redir := _redirect_if_unauth(request):
return redir
all_profiles = db.list_profiles() all_profiles = db.list_profiles()
for p in all_profiles: for p in all_profiles:
p["running"] = pm.is_running(p["id"]) p["running"] = pm.is_running(p["id"])
p["config"] = json.loads(p.get("config") or "{}") p["config"] = json.loads(p.get("config") or "{}")
return TEMPLATES.TemplateResponse("index.html", { return TEMPLATES.TemplateResponse(request, "index.html", {
"request": request,
"profiles": all_profiles, "profiles": all_profiles,
"bot_types": pm.BOT_TYPES, "bot_types": pm.BOT_TYPES,
}) })
@@ -96,7 +103,8 @@ async def index(request: Request):
@app.get("/profile/{profile_id}", response_class=HTMLResponse) @app.get("/profile/{profile_id}", response_class=HTMLResponse)
async def profile_page(request: Request, profile_id: int): async def profile_page(request: Request, profile_id: int):
_require_auth(request) if redir := _redirect_if_unauth(request):
return redir
profile = db.get_profile(profile_id) profile = db.get_profile(profile_id)
if not profile: if not profile:
raise HTTPException(404, "Profile not found") raise HTTPException(404, "Profile not found")
@@ -106,8 +114,7 @@ async def profile_page(request: Request, profile_id: int):
contacts = bot.contacts if bot else [] contacts = bot.contacts if bot else []
groups = bot.groups if bot else [] groups = bot.groups if bot else []
log_lines = bot.log_lines[-50:] if bot else [] log_lines = bot.log_lines[-50:] if bot else []
return TEMPLATES.TemplateResponse("profile.html", { return TEMPLATES.TemplateResponse(request, "profile.html", {
"request": request,
"profile": profile, "profile": profile,
"contacts": contacts, "contacts": contacts,
"groups": groups, "groups": groups,

View File

@@ -3,17 +3,28 @@ set -e
cd "$(dirname "$0")" cd "$(dirname "$0")"
if [ ! -d ".venv" ]; then # Bootstrap virtualenv — check for uvicorn specifically so partial installs are retried
echo "Creating virtualenv..." if [ ! -f ".venv/bin/uvicorn" ]; then
python3.12 -m venv .venv echo "Setting up virtualenv..."
.venv/bin/pip install -q -r requirements.txt rm -rf .venv
python3 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -r requirements.txt
fi fi
mkdir -p data/bots mkdir -p data/bots static
# Pre-download the libsimplex native binary so first Start doesn't stall
echo "Checking simplex-chat binary..."
.venv/bin/python -m simplex_chat install 2>/dev/null && echo " simplex binary ready." || echo " Binary not downloaded yet — use the Init button in Settings."
# Set token — override via: MANAGER_TOKEN=mysecret ./start.sh
export MANAGER_TOKEN="${MANAGER_TOKEN:-changeme}" export MANAGER_TOKEN="${MANAGER_TOKEN:-changeme}"
echo "Starting SimpleX Manager on http://0.0.0.0:8000" echo ""
echo " SimpleX Manager"
echo " URL: http://0.0.0.0:8000"
echo " Token: $MANAGER_TOKEN" echo " Token: $MANAGER_TOKEN"
echo ""
exec .venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000 --reload exec .venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000

View File

@@ -5,6 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}SimpleX Manager{% endblock %}</title> <title>{% block title %}SimpleX Manager{% endblock %}</title>
<script src="https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js"></script> <script src="https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js"></script>
<script>
// Inject auth token into every HTMX request automatically
document.addEventListener('htmx:configRequest', function(evt) {
const m = document.cookie.match(/(?:^|;\s*)token=([^;]+)/);
if (m) evt.detail.headers['X-Token'] = m[1];
});
</script>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

View File

@@ -28,12 +28,10 @@
<a href="/profile/{{ p.id }}" class="btn btn-ghost" style="padding: 6px 14px; font-size: 13px;">View</a> <a href="/profile/{{ p.id }}" class="btn btn-ghost" style="padding: 6px 14px; font-size: 13px;">View</a>
<button class="btn btn-success" style="padding: 6px 14px; font-size: 13px;" <button class="btn btn-success" style="padding: 6px 14px; font-size: 13px;"
hx-post="/api/profiles/{{ p.id }}/start" hx-post="/api/profiles/{{ p.id }}/start"
hx-headers='{"X-Token": "{{ request.cookies.get(\"token\", \"\") }}"}'
hx-swap="none" hx-swap="none"
onclick="this.textContent='Starting…'">Start</button> onclick="this.textContent='Starting…'">Start</button>
<button class="btn btn-danger" style="padding: 6px 14px; font-size: 13px;" <button class="btn btn-danger" style="padding: 6px 14px; font-size: 13px;"
hx-post="/api/profiles/{{ p.id }}/stop" hx-post="/api/profiles/{{ p.id }}/stop"
hx-headers='{"X-Token": "{{ request.cookies.get(\"token\", \"\") }}"}'
hx-swap="none" hx-swap="none"
onclick="this.textContent='Stopping…'">Stop</button> onclick="this.textContent='Stopping…'">Stop</button>
</div> </div>
@@ -95,9 +93,10 @@ document.getElementById('create-form').addEventListener('submit', async (e) => {
const welcome = fd.get('welcome_message') const welcome = fd.get('welcome_message')
if (welcome) config.welcome_message = welcome if (welcome) config.welcome_message = welcome
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''
const resp = await fetch('/api/profiles', { const resp = await fetch('/api/profiles', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Token': document.cookie.match(/token=([^;]+)/)?.[1] || ''}, headers: {'Content-Type': 'application/json', 'X-Token': token},
body: JSON.stringify({name: fd.get('name'), bot_type: fd.get('bot_type'), config}) body: JSON.stringify({name: fd.get('name'), bot_type: fd.get('bot_type'), config})
}) })
if (resp.ok) { if (resp.ok) {

View File

@@ -24,13 +24,11 @@
{% if profile.running %} {% if profile.running %}
<button class="btn btn-danger" <button class="btn btn-danger"
hx-post="/api/profiles/{{ profile.id }}/stop" hx-post="/api/profiles/{{ profile.id }}/stop"
hx-headers='{"X-Token": "{{ request.cookies.get(\"token\", \"\") }}"}'
hx-swap="none" hx-swap="none"
hx-on::after-request="location.reload()">Stop</button> hx-on::after-request="location.reload()">Stop</button>
{% else %} {% else %}
<button class="btn btn-success" <button class="btn btn-success"
hx-post="/api/profiles/{{ profile.id }}/start" hx-post="/api/profiles/{{ profile.id }}/start"
hx-headers='{"X-Token": "{{ request.cookies.get(\"token\", \"\") }}"}'
hx-swap="none" hx-swap="none"
hx-on::after-request="location.reload()">Start</button> hx-on::after-request="location.reload()">Start</button>
{% endif %} {% endif %}
@@ -137,7 +135,6 @@
<h2 style="margin:0;">Event Log</h2> <h2 style="margin:0;">Event Log</h2>
<button class="btn btn-ghost" style="font-size:12px;padding:4px 10px;" <button class="btn btn-ghost" style="font-size:12px;padding:4px 10px;"
hx-get="/api/profiles/{{ profile.id }}/status" hx-get="/api/profiles/{{ profile.id }}/status"
hx-headers='{"X-Token": "{{ request.cookies.get(\"token\", \"\") }}"}'
hx-swap="none" hx-swap="none"
hx-on::after-request="refreshLog(event)">Refresh</button> hx-on::after-request="refreshLog(event)">Refresh</button>
</div> </div>
@@ -153,9 +150,10 @@ document.getElementById('send-form').addEventListener('submit', async (e) => {
const fd = new FormData(e.target) const fd = new FormData(e.target)
const result = document.getElementById('send-result') const result = document.getElementById('send-result')
result.textContent = 'Sending…' result.textContent = 'Sending…'
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''
const resp = await fetch('/api/profiles/{{ profile.id }}/send', { const resp = await fetch('/api/profiles/{{ profile.id }}/send', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Token': document.cookie.match(/token=([^;]+)/)?.[1] || ''}, headers: {'Content-Type': 'application/json', 'X-Token': token},
body: JSON.stringify({to: fd.get('to'), text: fd.get('text')}) body: JSON.stringify({to: fd.get('to'), text: fd.get('text')})
}) })
const data = await resp.json() const data = await resp.json()
@@ -174,9 +172,10 @@ function refreshLog(event) {
function confirmDelete() { function confirmDelete() {
if (!confirm('Delete this profile? This cannot be undone.')) return if (!confirm('Delete this profile? This cannot be undone.')) return
const token = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)?.[1] || ''
fetch('/api/profiles/{{ profile.id }}', { fetch('/api/profiles/{{ profile.id }}', {
method: 'DELETE', method: 'DELETE',
headers: {'X-Token': document.cookie.match(/token=([^;]+)/)?.[1] || ''} headers: {'X-Token': token}
}).then(() => location.href = '/') }).then(() => location.href = '/')
} }