""" PST Indexer — Admin Panel (DEPRECATED / no longer launched). As of v2.2 the administration section runs inside the main application (app.py), served at /admin and reachable via the "Administration" button that is shown to admin users. The entrypoint no longer starts this module, and no separate admin port is exposed. This file is retained only for reference and for backwards compatibility with any custom tooling that imported it; it is safe to ignore. See app.py for the live administration endpoints (namespaced under /api/admin). Original description below. Separate FastAPI instance on its own port so operators can place it behind a more restrictive firewall rule or VPN than the main app. Exposes: - Admin login (separate session cookie, scoped "admin") - List, create, delete users - Toggle admin role - Reset passwords - Enroll / confirm / disable MFA for any user (admin-managed flow) - Self-service MFA for the currently logged-in admin """ import io import os from pathlib import Path from typing import Optional import qrcode from fastapi import ( Depends, FastAPI, HTTPException, Request, Response, status, ) from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse, JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel import auth from auth import ( ADMIN_SESSION_COOKIE_NAME, SESSION_TTL_SECONDS, User, begin_mfa_enrollment, confirm_mfa_enrollment, count_admins, count_users, create_session_token, create_user, delete_user, disable_mfa, find_user_by_id, find_user_by_username, get_totp_secret, list_users, login_limiter, record_login, require_admin_user, set_user_role, totp_provisioning_uri, update_password, verify_password, verify_totp, ) ADMIN_FRONTEND_DIR = Path(os.environ.get("ADMIN_FRONTEND_DIR", "/app/admin_frontend")) COOKIE_SECURE = os.environ.get("COOKIE_SECURE", "false").lower() == "true" app = FastAPI(title="PST Indexer Admin Panel") app.add_middleware( CORSMiddleware, allow_origins=[], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.on_event("startup") def _startup(): auth.init_auth_schema() def _set_admin_cookie(response: Response, token: str): response.set_cookie( key=ADMIN_SESSION_COOKIE_NAME, value=token, max_age=SESSION_TTL_SECONDS, httponly=True, secure=COOKIE_SECURE, samesite="lax", path="/", ) def _clear_admin_cookie(response: Response): response.delete_cookie(key=ADMIN_SESSION_COOKIE_NAME, path="/") # --------------------------------------------------------------------------- # Auth # --------------------------------------------------------------------------- class LoginPayload(BaseModel): username: str password: str totp_code: Optional[str] = None @app.get("/api/health") def health(): return {"status": "ok", "admins": count_admins(), "users": count_users()} @app.get("/api/setup/status") def setup_status(): """True if no users exist. Admin UI redirects to main app to complete setup.""" return {"needs_setup": count_users() == 0} @app.post("/api/login") def admin_login(payload: LoginPayload, request: Request, response: Response): ip = request.client.host if request.client else "unknown" rate_key = f"admin:{ip}:{payload.username.lower()}" if not login_limiter.check(rate_key): raise HTTPException(429, "Too many login attempts. Please wait a few minutes.") found = find_user_by_username(payload.username) if not found: login_limiter.record(rate_key) raise HTTPException(401, "Invalid credentials") user, pw_hash = found if not verify_password(pw_hash, payload.password): login_limiter.record(rate_key) raise HTTPException(401, "Invalid credentials") if not user.is_admin: raise HTTPException(403, "Admin access required") if user.mfa_enabled: if not payload.totp_code: return JSONResponse( status_code=401, content={"detail": "MFA required", "mfa_required": True}, ) secret = get_totp_secret(user.id) if not secret or not verify_totp(secret, payload.totp_code): login_limiter.record(rate_key) raise HTTPException(401, "Invalid MFA code") login_limiter.reset(rate_key) record_login(user.id) token = create_session_token(user, scope="admin") _set_admin_cookie(response, token) return {"user": user.public_dict()} @app.post("/api/logout") def admin_logout(response: Response): _clear_admin_cookie(response) return {"ok": True} @app.get("/api/me") def me(user: User = Depends(require_admin_user)): return {"user": user.public_dict()} # --------------------------------------------------------------------------- # User management # --------------------------------------------------------------------------- class CreateUserPayload(BaseModel): username: str password: str role: str = "user" class UpdatePasswordPayload(BaseModel): password: str class UpdateRolePayload(BaseModel): role: str @app.get("/api/users") def api_list_users(_: User = Depends(require_admin_user)): return [u.public_dict() for u in list_users()] @app.post("/api/users") def api_create_user( payload: CreateUserPayload, admin: User = Depends(require_admin_user), ): try: user = create_user(payload.username, payload.password, role=payload.role) except ValueError as e: raise HTTPException(400, str(e)) return user.public_dict() @app.delete("/api/users/{user_id}") def api_delete_user(user_id: str, admin: User = Depends(require_admin_user)): target = find_user_by_id(user_id) if not target: raise HTTPException(404, "User not found") # Prevent deleting yourself if target.id == admin.id: raise HTTPException(400, "You cannot delete your own account") # Prevent deleting the last admin if target.is_admin and count_admins() <= 1: raise HTTPException(400, "Cannot delete the last remaining admin") delete_user(user_id) return {"deleted": user_id} @app.put("/api/users/{user_id}/role") def api_set_role( user_id: str, payload: UpdateRolePayload, admin: User = Depends(require_admin_user), ): target = find_user_by_id(user_id) if not target: raise HTTPException(404, "User not found") if target.id == admin.id and payload.role != "admin": raise HTTPException(400, "You cannot demote your own account") if (target.is_admin and payload.role != "admin" and count_admins() <= 1): raise HTTPException(400, "Cannot demote the last remaining admin") try: set_user_role(user_id, payload.role) except ValueError as e: raise HTTPException(400, str(e)) return find_user_by_id(user_id).public_dict() @app.put("/api/users/{user_id}/password") def api_update_password( user_id: str, payload: UpdatePasswordPayload, admin: User = Depends(require_admin_user), ): if not find_user_by_id(user_id): raise HTTPException(404, "User not found") try: update_password(user_id, payload.password) except ValueError as e: raise HTTPException(400, str(e)) return {"ok": True} # --------------------------------------------------------------------------- # MFA management # --------------------------------------------------------------------------- class ConfirmMfaPayload(BaseModel): totp_code: str @app.post("/api/users/{user_id}/mfa/begin") def api_begin_mfa(user_id: str, admin: User = Depends(require_admin_user)): """Provision a new TOTP secret for the target user (admin action). Returns the secret + otpauth URI so the admin can show it to the user.""" target = find_user_by_id(user_id) if not target: raise HTTPException(404, "User not found") secret, uri = begin_mfa_enrollment(user_id) return { "secret": secret, "otpauth_uri": uri, "username": target.username, "qr_url": f"/api/users/{user_id}/mfa/qr", } @app.get("/api/users/{user_id}/mfa/qr") def api_mfa_qr(user_id: str, admin: User = Depends(require_admin_user)): """Render a PNG QR code of the target user's current (unconfirmed) TOTP URI.""" target = find_user_by_id(user_id) if not target: raise HTTPException(404, "User not found") secret = get_totp_secret(user_id) if not secret: raise HTTPException(400, "No MFA enrollment in progress") uri = totp_provisioning_uri(secret, target.username) qr = qrcode.QRCode(box_size=6, border=2) qr.add_data(uri) qr.make(fit=True) img = qr.make_image(fill_color="black", back_color="white") buf = io.BytesIO() img.save(buf, format="PNG") buf.seek(0) return StreamingResponse(buf, media_type="image/png") @app.post("/api/users/{user_id}/mfa/confirm") def api_confirm_mfa( user_id: str, payload: ConfirmMfaPayload, admin: User = Depends(require_admin_user), ): if not find_user_by_id(user_id): raise HTTPException(404, "User not found") if not confirm_mfa_enrollment(user_id, payload.totp_code): raise HTTPException(400, "Invalid MFA code — enrollment not confirmed") return find_user_by_id(user_id).public_dict() @app.post("/api/users/{user_id}/mfa/disable") def api_disable_mfa(user_id: str, admin: User = Depends(require_admin_user)): target = find_user_by_id(user_id) if not target: raise HTTPException(404, "User not found") disable_mfa(user_id) return find_user_by_id(user_id).public_dict() # --------------------------------------------------------------------------- # Folder management (admin) # --------------------------------------------------------------------------- import uuid as _uuid from datetime import datetime, timezone from pathlib import Path as _Path from auth import get_db UPLOAD_DIR = _Path(os.environ.get("DATA_DIR", "/app/data")) / "uploads" class AdminFolderPayload(BaseModel): name: str def _validate_name(name: str) -> str: name = (name or "").strip() if not name: raise HTTPException(400, "Folder name cannot be empty") if len(name) > 80: raise HTTPException(400, "Folder name too long (max 80 characters)") return name @app.get("/api/folders") def admin_list_folders(_: User = Depends(require_admin_user)): with get_db() as conn: rows = conn.execute( """SELECT f.id, f.name, f.created_at, f.created_by, COUNT(p.id) AS file_count FROM pst_folders f LEFT JOIN pst_files p ON p.folder_id = f.id GROUP BY f.id ORDER BY f.name COLLATE NOCASE""" ).fetchall() return [dict(r) for r in rows] @app.post("/api/folders") def admin_create_folder(payload: AdminFolderPayload, admin: User = Depends(require_admin_user)): name = _validate_name(payload.name) with get_db() as conn: existing = conn.execute( "SELECT id FROM pst_folders WHERE name = ? COLLATE NOCASE", (name,) ).fetchone() if existing: raise HTTPException(409, "A folder with that name already exists") folder_id = _uuid.uuid4().hex conn.execute( """INSERT INTO pst_folders (id, name, created_at, created_by) VALUES (?, ?, ?, ?)""", (folder_id, name, datetime.now(timezone.utc).isoformat(), admin.id), ) conn.commit() return {"id": folder_id, "name": name, "file_count": 0} @app.put("/api/folders/{folder_id}") def admin_rename_folder(folder_id: str, payload: AdminFolderPayload, admin: User = Depends(require_admin_user)): name = _validate_name(payload.name) with get_db() as conn: if not conn.execute("SELECT id FROM pst_folders WHERE id = ?", (folder_id,)).fetchone(): raise HTTPException(404, "Folder not found") clash = conn.execute( "SELECT id FROM pst_folders WHERE name = ? COLLATE NOCASE AND id != ?", (name, folder_id), ).fetchone() if clash: raise HTTPException(409, "A folder with that name already exists") conn.execute("UPDATE pst_folders SET name = ? WHERE id = ?", (name, folder_id)) conn.commit() return {"id": folder_id, "name": name} @app.delete("/api/folders/{folder_id}") def admin_delete_folder(folder_id: str, admin: User = Depends(require_admin_user)): """Delete a folder. PSTs inside it become uncategorized (folder_id = NULL).""" with get_db() as conn: if not conn.execute("SELECT id FROM pst_folders WHERE id = ?", (folder_id,)).fetchone(): raise HTTPException(404, "Folder not found") conn.execute("UPDATE pst_files SET folder_id = NULL WHERE folder_id = ?", (folder_id,)) conn.execute("DELETE FROM pst_folders WHERE id = ?", (folder_id,)) conn.commit() return {"deleted": folder_id} # --------------------------------------------------------------------------- # PST file management (admin) # --------------------------------------------------------------------------- @app.get("/api/pst-files") def admin_list_pst_files(_: User = Depends(require_admin_user)): with get_db() as conn: rows = conn.execute( """SELECT p.id, p.original_name, p.uploaded_at, p.size_bytes, p.status, p.message_count, p.error, p.folder_id, f.name AS folder_name, p.uploaded_by FROM pst_files p LEFT JOIN pst_folders f ON f.id = p.folder_id ORDER BY p.uploaded_at DESC""" ).fetchall() return [dict(r) for r in rows] @app.delete("/api/pst-files/{pst_id}") def admin_delete_pst(pst_id: str, admin: User = Depends(require_admin_user)): with get_db() as conn: row = conn.execute( "SELECT filename FROM pst_files WHERE id = ?", (pst_id,) ).fetchone() if not row: raise HTTPException(404, "PST not found") conn.execute("DELETE FROM messages WHERE pst_id = ?", (pst_id,)) conn.execute("DELETE FROM pst_files WHERE id = ?", (pst_id,)) conn.commit() try: (UPLOAD_DIR / row["filename"]).unlink(missing_ok=True) except Exception: pass return {"deleted": pst_id} class AdminAssignPayload(BaseModel): folder_id: Optional[str] = None @app.put("/api/pst-files/{pst_id}/folder") def admin_assign_pst_to_folder(pst_id: str, payload: AdminAssignPayload, admin: User = Depends(require_admin_user)): with get_db() as conn: if not conn.execute("SELECT id FROM pst_files WHERE id = ?", (pst_id,)).fetchone(): raise HTTPException(404, "PST not found") if payload.folder_id: if not conn.execute("SELECT id FROM pst_folders WHERE id = ?", (payload.folder_id,)).fetchone(): raise HTTPException(404, "Folder not found") conn.execute("UPDATE pst_files SET folder_id = ? WHERE id = ?", (payload.folder_id, pst_id)) conn.commit() return {"pst_id": pst_id, "folder_id": payload.folder_id} # --------------------------------------------------------------------------- # Folder permissions (admin-only) # Controls which user can access which folder. # Admins ALWAYS have access to every folder regardless of this table — # these grants are for non-admin users. # --------------------------------------------------------------------------- class PermissionToggle(BaseModel): granted: bool @app.get("/api/permissions") def admin_list_permissions(_: User = Depends(require_admin_user)): """Return the full matrix: users, folders, and all grants.""" with get_db() as conn: users = conn.execute("SELECT * FROM users ORDER BY created_at ASC").fetchall() folders = conn.execute( "SELECT id, name FROM pst_folders ORDER BY name COLLATE NOCASE" ).fetchall() grants = conn.execute( "SELECT user_id, folder_id, granted_at FROM folder_permissions" ).fetchall() from auth import _row_to_user user_list = [] for u in users: pub = _row_to_user(u).public_dict() user_list.append(pub) return { "users": user_list, "folders": [dict(f) for f in folders], "grants": [dict(g) for g in grants], } @app.get("/api/users/{user_id}/folders") def admin_user_folders(user_id: str, _: User = Depends(require_admin_user)): """Folders a given user has been granted.""" target = find_user_by_id(user_id) if not target: raise HTTPException(404, "User not found") with get_db() as conn: rows = conn.execute( """SELECT f.id, f.name, p.granted_at FROM folder_permissions p JOIN pst_folders f ON f.id = p.folder_id WHERE p.user_id = ? ORDER BY f.name COLLATE NOCASE""", (user_id,), ).fetchall() return { "user": target.public_dict(), "folders": [dict(r) for r in rows], } @app.get("/api/folders/{folder_id}/users") def admin_folder_users(folder_id: str, _: User = Depends(require_admin_user)): """Users granted on a given folder.""" with get_db() as conn: folder = conn.execute( "SELECT id, name FROM pst_folders WHERE id = ?", (folder_id,) ).fetchone() if not folder: raise HTTPException(404, "Folder not found") grant_rows = conn.execute( """SELECT user_id, granted_at FROM folder_permissions WHERE folder_id = ?""", (folder_id,), ).fetchall() # Hydrate user details (usernames are encrypted — need decrypt via find_user_by_id) users_out = [] for r in grant_rows: u = find_user_by_id(r["user_id"]) if u: users_out.append({ **u.public_dict(), "granted_at": r["granted_at"], }) users_out.sort(key=lambda x: x["username"].lower()) return { "folder": dict(folder), "users": users_out, } @app.put("/api/permissions/{user_id}/{folder_id}") def admin_toggle_permission( user_id: str, folder_id: str, payload: PermissionToggle, admin: User = Depends(require_admin_user), ): target = find_user_by_id(user_id) if not target: raise HTTPException(404, "User not found") if target.is_admin: # Admins implicitly have access to everything; refuse explicit grants # so the UI doesn't confuse admins with checked-but-meaningless rows. raise HTTPException( 400, "Admins have access to all folders automatically; permissions " "only apply to non-admin users.", ) with get_db() as conn: if not conn.execute("SELECT id FROM pst_folders WHERE id = ?", (folder_id,)).fetchone(): raise HTTPException(404, "Folder not found") if payload.granted: conn.execute( """INSERT OR IGNORE INTO folder_permissions (user_id, folder_id, granted_at, granted_by) VALUES (?, ?, ?, ?)""", (user_id, folder_id, datetime.now(timezone.utc).isoformat(), admin.id), ) else: conn.execute( "DELETE FROM folder_permissions WHERE user_id = ? AND folder_id = ?", (user_id, folder_id), ) conn.commit() return {"user_id": user_id, "folder_id": folder_id, "granted": payload.granted} # --------------------------------------------------------------------------- # Static admin frontend # --------------------------------------------------------------------------- if ADMIN_FRONTEND_DIR.exists(): app.mount("/", StaticFiles(directory=str(ADMIN_FRONTEND_DIR), html=True), name="admin")