598 lines
20 KiB
Python
598 lines
20 KiB
Python
"""
|
|||
|
|
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")
|