v_2.2
This commit is contained in:
@@ -0,0 +1,597 @@
|
||||
"""
|
||||
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")
|
||||
Reference in New Issue
Block a user