SysOps: approved_backup — 2026-08-16 11:01 UTC

This commit is contained in:
sysops
2026-08-16 11:01:58 +00:00
parent 22a17ed048
commit 1dd607b060
46 changed files with 3734 additions and 147 deletions
+5 -2
View File
@@ -8,7 +8,7 @@ from datetime import datetime
from typing import Any, Optional
import httpx
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi import Request, APIRouter, File, Form, HTTPException, UploadFile
from pydantic import BaseModel, Field
from app.config import settings
@@ -1019,14 +1019,17 @@ async def ai_generate_post_draft(body: AIPostDraftBody):
@herman_api.post("/chat")
async def herman_chat_api(body: HermanChatBody):
async def herman_chat_api(request: Request, body: HermanChatBody):
if not body.message.strip():
raise HTTPException(400, "message required")
user = getattr(request.state, "user", None) or {}
result = await herman_service.chat(
body.message.strip(),
channel=(body.channel or "cockpit").strip(),
session_id=body.session_id,
confirm_action_id=body.confirm_action_id,
username=(user.get("username") if isinstance(user, dict) else None),
role=(user.get("role") if isinstance(user, dict) else None),
)
return {"ok": True, **result}
+10 -2
View File
@@ -1,7 +1,7 @@
from pathlib import Path
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from app.db import execute, fetch_all
@@ -22,7 +22,7 @@ AGENT_STATUSES = [
@router.get("")
async def agents_page(request: Request):
active_tab = request.query_params.get("tab", "souls")
if active_tab not in {"souls", "mesh", "terminals", "approvals"}:
if active_tab not in {"souls", "mesh", "terminals", "approvals", "warroom"}:
active_tab = "souls"
events: list = []
try:
@@ -52,6 +52,14 @@ async def agents_page(request: Request):
)
@router.get("/warroom/popup", response_class=HTMLResponse)
async def agents_warroom_popup(request: Request):
return templates.TemplateResponse(
"agents_warroom_popup.html",
{"request": request, "page_title": "War Room"},
)
@router.post("/approve/{event_id}")
async def approve_event(event_id: int, next_url: str = Form("/")):
try:
+119 -1
View File
@@ -5,7 +5,7 @@ import asyncio
import json
from typing import Any, Optional
from fastapi import APIRouter, HTTPException
from fastapi import File, UploadFile, APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
@@ -545,3 +545,121 @@ def api_execute_approved_request(request_id: int, body: ExecuteBody) -> dict[str
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"ok": True, "request": req}
# --- War Room chat ---
class WarroomMessageBody(BaseModel):
content: str = Field("", max_length=8000)
attachments: list[dict] = Field(default_factory=list)
@router.get("/warroom/rooms")
async def warroom_list_rooms() -> dict[str, Any]:
from app.services import agent_chat
agent_chat.ensure_warroom()
return {"items": agent_chat.list_rooms()}
@router.post("/warroom/rooms/dm/{agent_key}")
async def warroom_open_dm(agent_key: str) -> dict[str, Any]:
from app.services import agent_chat
try:
room = agent_chat.get_or_create_dm(agent_key)
except ValueError as exc:
raise HTTPException(404, str(exc)) from exc
return {"room": room}
@router.get("/warroom/rooms/{room_id}/messages")
async def warroom_list_messages(room_id: int, limit: int = 100, after_id: int | None = None) -> dict[str, Any]:
from app.services import agent_chat
room = agent_chat.get_room(room_id)
if not room:
raise HTTPException(404, "Room niet gevonden")
return {"room": room, "items": agent_chat.list_messages(room_id, limit=min(limit, 200), after_id=after_id)}
@router.post("/warroom/rooms/{room_id}/messages")
async def warroom_post_message(request: Request, room_id: int, body: WarroomMessageBody) -> dict[str, Any]:
from app.services import agent_chat
user = getattr(request.state, "user", None) or {}
try:
result = await agent_chat.post_user_message(
room_id,
body.content,
username=(user.get("username") if isinstance(user, dict) else None),
role=(user.get("role") if isinstance(user, dict) else None),
sender_avatar_url=(user.get("avatar_url") if isinstance(user, dict) else None),
attachments=body.attachments,
)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return result
@router.post("/warroom/rooms/{room_id}/messages/stream")
async def warroom_post_message_stream(request: Request, room_id: int, body: WarroomMessageBody):
"""SSE stream: user → typing tokens → agent message(s) → done."""
import json
from app.services import agent_chat
user = getattr(request.state, "user", None) or {}
async def gen():
try:
async for ev in agent_chat.post_user_message_stream(
room_id,
body.content,
username=(user.get("username") if isinstance(user, dict) else None),
role=(user.get("role") if isinstance(user, dict) else None),
sender_avatar_url=(user.get("avatar_url") if isinstance(user, dict) else None),
attachments=body.attachments,
):
yield f"data: {json.dumps(ev, default=str)}\n\n"
except ValueError as exc:
yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n"
except Exception as exc:
yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n"
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.post("/warroom/upload")
async def warroom_upload(file: UploadFile = File(...)) -> dict[str, Any]:
"""Upload a file to share in War Room (images/docs)."""
from pathlib import Path as P
import uuid
import re
raw_name = (file.filename or "upload.bin").strip()
safe = re.sub(r"[^a-zA-Z0-9._-]+", "_", raw_name)[:120] or "upload.bin"
ext = P(safe).suffix.lower()
allowed = {
".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".txt", ".csv",
".xlsx", ".xls", ".doc", ".docx", ".zip", ".json", ".md",
}
if ext not in allowed:
raise HTTPException(400, f"Bestandstype niet toegestaan: {ext or '(geen)'}")
data = await file.read()
if len(data) > 12 * 1024 * 1024:
raise HTTPException(400, "Max 12MB per bestand")
dest_dir = P(__file__).resolve().parent.parent.parent / "static" / "uploads" / "warroom"
dest_dir.mkdir(parents=True, exist_ok=True)
fname = f"{uuid.uuid4().hex[:12]}_{safe}"
(dest_dir / fname).write_bytes(data)
url = f"/static/uploads/warroom/{fname}"
return {
"ok": True,
"url": url,
"name": raw_name[:180],
"mime": file.content_type or "application/octet-stream",
"size": len(data),
}
+231
View File
@@ -0,0 +1,231 @@
"""Login / logout / me + user admin API."""
from __future__ import annotations
from pathlib import Path
import uuid
from typing import Any, Optional
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from app import auth_users
router = APIRouter(tags=["auth"])
api_router = APIRouter(prefix="/api/auth", tags=["auth"])
users_api = APIRouter(prefix="/api/settings/users", tags=["users"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
AVATAR_DIR = BASE_DIR / "static" / "uploads" / "avatars"
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
ALLOWED_AVATAR = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
async def _save_avatar_file(user_id: int, file: UploadFile) -> str:
raw_name = (file.filename or "avatar.jpg").lower()
ext = Path(raw_name).suffix
if ext not in ALLOWED_AVATAR:
raise HTTPException(status_code=400, detail="Alleen JPG, PNG, WEBP of GIF")
data = await file.read()
if len(data) > 3 * 1024 * 1024:
raise HTTPException(status_code=400, detail="Max 3 MB")
# strip old files for this user
for old in AVATAR_DIR.glob(f"user_{user_id}_*"):
try:
old.unlink()
except OSError:
pass
fname = f"user_{user_id}_{uuid.uuid4().hex[:10]}{ext}"
path = AVATAR_DIR / fname
path.write_bytes(data)
return f"/static/uploads/avatars/{fname}"
class LoginBody(BaseModel):
username: str
password: str
class UserCreateBody(BaseModel):
username: str
password: str
display_name: Optional[str] = None
role: str = "user"
permissions: dict[str, Any] = Field(default_factory=dict)
class UserUpdateBody(BaseModel):
display_name: Optional[str] = None
role: Optional[str] = None
is_active: Optional[bool] = None
password: Optional[str] = None
permissions: Optional[dict[str, Any]] = None
def _session_user(request: Request) -> dict[str, Any] | None:
return getattr(request.state, "user", None)
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, next: str = "/"):
if _session_user(request):
return RedirectResponse(next or "/", status_code=303)
return templates.TemplateResponse(
"login.html",
{"request": request, "page_title": "Inloggen", "next": next or "/", "error": None},
)
@router.post("/login")
async def login_submit(request: Request):
form = await request.form()
username = str(form.get("username") or "").strip()
password = str(form.get("password") or "")
next_url = str(form.get("next") or "/")
if not next_url.startswith("/"):
next_url = "/"
user = auth_users.authenticate(username, password)
if not user:
return templates.TemplateResponse(
"login.html",
{
"request": request,
"page_title": "Inloggen",
"next": next_url,
"error": "Ongeldige gebruikersnaam of wachtwoord",
"username": username,
},
status_code=401,
)
request.session.clear()
request.session["user_id"] = user["id"]
request.session["username"] = user["username"]
return RedirectResponse(next_url, status_code=303)
@router.post("/logout")
@router.get("/logout")
async def logout(request: Request):
request.session.clear()
return RedirectResponse("/login", status_code=303)
@api_router.post("/login")
async def api_login(request: Request, body: LoginBody):
user = auth_users.authenticate(body.username, body.password)
if not user:
raise HTTPException(status_code=401, detail="Ongeldige login")
request.session.clear()
request.session["user_id"] = user["id"]
request.session["username"] = user["username"]
return {"ok": True, "user": user}
@api_router.post("/logout")
async def api_logout(request: Request):
request.session.clear()
return {"ok": True}
@api_router.get("/me")
async def api_me(request: Request):
user = _session_user(request)
if not user:
raise HTTPException(status_code=401, detail="Niet ingelogd")
return {
"user": user,
"pages": auth_users.PAGE_CATALOG,
"can_manage_users": auth_users.can_manage_users(user),
}
def _require_user_admin(request: Request) -> dict[str, Any]:
user = _session_user(request)
if not user:
raise HTTPException(status_code=401, detail="Niet ingelogd")
if not auth_users.can_manage_users(user):
raise HTTPException(status_code=403, detail="Geen rechten om gebruikers te beheren")
return user
@users_api.get("")
async def users_list(request: Request):
_require_user_admin(request)
return {"users": auth_users.list_users(), "pages": auth_users.PAGE_CATALOG}
@users_api.post("")
async def users_create(request: Request, body: UserCreateBody):
_require_user_admin(request)
try:
user = auth_users.create_user(
body.username, body.password, body.display_name, body.role, body.permissions or {}
)
except Exception as e:
msg = str(e)
if "unique" in msg.lower() or "duplicate" in msg.lower():
raise HTTPException(status_code=400, detail="Gebruikersnaam bestaat al") from e
raise HTTPException(status_code=400, detail=msg) from e
return {"user": user}
@users_api.put("/{user_id}")
async def users_update(request: Request, user_id: int, body: UserUpdateBody):
_require_user_admin(request)
try:
user = auth_users.update_user(
user_id,
display_name=body.display_name,
role=body.role,
is_active=body.is_active,
permissions=body.permissions,
password=body.password or None,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"user": user}
@users_api.delete("/{user_id}")
async def users_delete(request: Request, user_id: int):
actor = _require_user_admin(request)
try:
auth_users.delete_user(user_id, actor_id=actor["id"])
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"ok": True}
@api_router.post("/me/avatar")
async def upload_my_avatar(request: Request, file: UploadFile = File(...)):
user = _session_user(request)
if not user:
raise HTTPException(status_code=401, detail="Niet ingelogd")
url = await _save_avatar_file(user["id"], file)
updated = auth_users.set_avatar(user["id"], url)
request.session["username"] = updated["username"]
return {"user": updated, "avatar_url": url}
@users_api.post("/{user_id}/avatar")
async def upload_user_avatar(request: Request, user_id: int, file: UploadFile = File(...)):
_require_user_admin(request)
url = await _save_avatar_file(user_id, file)
updated = auth_users.set_avatar(user_id, url)
return {"user": updated, "avatar_url": url}
@users_api.delete("/{user_id}/avatar")
async def delete_user_avatar(request: Request, user_id: int):
actor = _require_user_admin(request)
for old in AVATAR_DIR.glob(f"user_{user_id}_*"):
try:
old.unlink()
except OSError:
pass
updated = auth_users.set_avatar(user_id, None)
return {"user": updated}
+9 -1
View File
@@ -1,7 +1,10 @@
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
from app import auth_users
router = APIRouter(tags=["settings"])
BASE_DIR = Path(__file__).resolve().parent.parent.parent
@@ -10,15 +13,20 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
@router.get("/settings")
async def settings_page(request: Request, tab: str = "email"):
allowed = ("email", "general", "permissions", "social")
allowed = ("email", "general", "permissions", "social", "users")
active = tab if tab in allowed else "email"
if tab == "ai":
active = "email"
user = getattr(request.state, "user", None)
if active == "users" and not auth_users.can_manage_users(user):
return RedirectResponse("/settings?tab=email", status_code=303)
return templates.TemplateResponse(
"settings.html",
{
"request": request,
"page_title": "Settings",
"active_tab": active,
"can_manage_users": auth_users.can_manage_users(user),
"current_user": user,
},
)