Fix CSS media query, add Matrix user management UI and API endpoints

Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/84f10dbb-7db4-4f3f-b9b4-0f20455ac3e0

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-03 15:58:33 +00:00
committed by GitHub
parent e90fbccde0
commit fc2c7e7928
3 changed files with 444 additions and 5 deletions

View File

@@ -10,6 +10,8 @@ import os
import re
import socket
import subprocess
import urllib.error
import urllib.parse
import urllib.request
from fastapi import FastAPI, HTTPException
@@ -1110,6 +1112,185 @@ async def api_domains_status():
return {"domains": domains}
# ── Matrix user management ────────────────────────────────────────
MATRIX_USERS_FILE = "/var/lib/secrets/matrix-users"
MATRIX_DOMAINS_FILE = "/var/lib/domains/matrix"
_SAFE_USERNAME_RE = re.compile(r'^[a-z0-9._\-]+$')
def _validate_matrix_username(username: str) -> bool:
"""Return True if username is a valid Matrix localpart."""
return bool(username) and len(username) <= 255 and bool(_SAFE_USERNAME_RE.match(username))
def _parse_matrix_admin_creds() -> tuple[str, str]:
"""Parse admin username and password from the matrix-users credentials file.
Returns (localpart, password) for the admin account.
Raises FileNotFoundError if the file does not exist.
Raises ValueError if the file cannot be parsed.
"""
with open(MATRIX_USERS_FILE, "r") as f:
content = f.read()
admin_user: str | None = None
admin_pass: str | None = None
in_admin_section = False
for line in content.splitlines():
stripped = line.strip()
if stripped == "[ Admin Account ]":
in_admin_section = True
continue
if stripped.startswith("[ ") and in_admin_section:
break
if in_admin_section:
if stripped.startswith("Username:"):
raw = stripped[len("Username:"):].strip()
# Format is @localpart:domain — extract localpart
if raw.startswith("@") and ":" in raw:
admin_user = raw[1:raw.index(":")]
else:
admin_user = raw
elif stripped.startswith("Password:"):
admin_pass = stripped[len("Password:"):].strip()
if not admin_user or not admin_pass:
raise ValueError("Could not parse admin credentials from matrix-users file")
if "(pre-existing" in admin_pass:
raise ValueError(
"Admin password is not stored (user was pre-existing). "
"Please reset the admin password manually before using this feature."
)
return admin_user, admin_pass
def _matrix_get_admin_token(domain: str, admin_user: str, admin_pass: str) -> str:
"""Log in to the local Synapse instance and return an access token."""
url = "http://[::1]:8008/_matrix/client/v3/login"
payload = json.dumps({
"type": "m.login.password",
"identifier": {"type": "m.id.user", "user": admin_user},
"password": admin_pass,
}).encode()
req = urllib.request.Request(
url, data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=15) as resp:
body = json.loads(resp.read())
token: str = body.get("access_token", "")
if not token:
raise ValueError("No access_token in Synapse login response")
return token
class MatrixCreateUserRequest(BaseModel):
username: str
password: str
admin: bool = False
@app.post("/api/matrix/create-user")
async def api_matrix_create_user(req: MatrixCreateUserRequest):
"""Create a new Matrix user via register_new_matrix_user."""
if not _validate_matrix_username(req.username):
raise HTTPException(status_code=400, detail="Invalid username. Use only lowercase letters, digits, '.', '_', '-'.")
if not req.password:
raise HTTPException(status_code=400, detail="Password must not be empty.")
admin_flag = ["-a"] if req.admin else ["--no-admin"]
cmd = [
"register_new_matrix_user",
"-c", "/run/matrix-synapse/runtime-config.yaml",
"-u", req.username,
"-p", req.password,
*admin_flag,
"http://localhost:8008",
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
except FileNotFoundError:
raise HTTPException(status_code=500, detail="register_new_matrix_user not found on this system.")
except subprocess.TimeoutExpired:
raise HTTPException(status_code=500, detail="Command timed out.")
output = (result.stdout + result.stderr).strip()
if result.returncode != 0:
# Surface the actual error from the tool (e.g. "User ID already taken")
raise HTTPException(status_code=400, detail=output or "Failed to create user.")
return {"ok": True, "username": req.username}
class MatrixChangePasswordRequest(BaseModel):
username: str
new_password: str
@app.post("/api/matrix/change-password")
async def api_matrix_change_password(req: MatrixChangePasswordRequest):
"""Change a Matrix user's password via the Synapse Admin API."""
if not _validate_matrix_username(req.username):
raise HTTPException(status_code=400, detail="Invalid username. Use only lowercase letters, digits, '.', '_', '-'.")
if not req.new_password:
raise HTTPException(status_code=400, detail="New password must not be empty.")
# Read domain
try:
with open(MATRIX_DOMAINS_FILE, "r") as f:
domain = f.read().strip()
except FileNotFoundError:
raise HTTPException(status_code=500, detail="Matrix domain not configured.")
# Parse admin credentials
try:
admin_user, admin_pass = _parse_matrix_admin_creds()
except FileNotFoundError:
raise HTTPException(status_code=500, detail="Matrix credentials file not found.")
except ValueError as exc:
raise HTTPException(status_code=500, detail=str(exc))
# Obtain admin access token
loop = asyncio.get_event_loop()
try:
token = await loop.run_in_executor(
None, _matrix_get_admin_token, domain, admin_user, admin_pass
)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Could not authenticate as admin: {exc}")
# Call Synapse Admin API to reset the password
target_user_id = f"@{req.username}:{domain}"
url = f"http://[::1]:8008/_synapse/admin/v2/users/{urllib.parse.quote(target_user_id, safe='@:')}"
payload = json.dumps({"password": req.new_password}).encode()
api_req = urllib.request.Request(
url, data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
},
method="PUT",
)
try:
with urllib.request.urlopen(api_req, timeout=15) as resp:
resp.read()
except urllib.error.HTTPError as exc:
body = exc.read().decode(errors="replace")
try:
detail = json.loads(body).get("error", body)
except Exception:
detail = body
raise HTTPException(status_code=400, detail=detail)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Admin API call failed: {exc}")
return {"ok": True, "username": req.username}
# ── Startup: seed the internal IP file immediately ───────────────
@app.on_event("startup")