1235 lines
44 KiB
Python
1235 lines
44 KiB
Python
"""
|
|
PST Indexer — Main application (port 8000).
|
|
|
|
Serves:
|
|
- The web UI (login, setup wizard, archive browser)
|
|
- REST API for PST upload, indexing, search
|
|
- All data-access endpoints require an authenticated session
|
|
|
|
First-run behavior:
|
|
- If no users exist, the UI routes users to a setup wizard.
|
|
- /api/setup is only callable while the users table is empty; after the first
|
|
admin is created, the endpoint returns 409.
|
|
"""
|
|
import io
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import uuid
|
|
import asyncio
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import aiofiles
|
|
import pypff
|
|
import qrcode
|
|
from fastapi import (
|
|
BackgroundTasks, Depends, FastAPI, File, HTTPException,
|
|
Request, Response, UploadFile, status,
|
|
)
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse, FileResponse, StreamingResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from pydantic import BaseModel
|
|
|
|
import auth
|
|
from auth import (
|
|
SESSION_COOKIE_NAME, SESSION_TTL_SECONDS,
|
|
User, count_users, count_admins, create_user, find_user_by_username,
|
|
find_user_by_id, get_totp_secret, verify_password, verify_totp,
|
|
record_login, create_session_token, login_limiter, require_app_user,
|
|
require_app_admin, get_db,
|
|
# Administration (merged in from the former standalone admin panel)
|
|
begin_mfa_enrollment, confirm_mfa_enrollment, delete_user, disable_mfa,
|
|
list_users, set_user_role, totp_provisioning_uri, update_password,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config
|
|
# ---------------------------------------------------------------------------
|
|
DATA_DIR = Path(os.environ.get("DATA_DIR", "/app/data"))
|
|
UPLOAD_DIR = DATA_DIR / "uploads"
|
|
FRONTEND_DIR = Path(os.environ.get("FRONTEND_DIR", "/app/frontend"))
|
|
ADMIN_FRONTEND_DIR = Path(os.environ.get("ADMIN_FRONTEND_DIR", "/app/admin_frontend"))
|
|
|
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Cookie security. Flip to True when serving over HTTPS.
|
|
COOKIE_SECURE = os.environ.get("COOKIE_SECURE", "false").lower() == "true"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# App DB schema (messages + pst_files)
|
|
# ---------------------------------------------------------------------------
|
|
def init_app_schema():
|
|
with get_db() as conn:
|
|
conn.executescript("""
|
|
CREATE TABLE IF NOT EXISTS pst_folders (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
created_by TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS folder_permissions (
|
|
user_id TEXT NOT NULL,
|
|
folder_id TEXT NOT NULL,
|
|
granted_at TEXT NOT NULL,
|
|
granted_by TEXT,
|
|
PRIMARY KEY (user_id, folder_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_folder_perms_user ON folder_permissions(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_folder_perms_folder ON folder_permissions(folder_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS pst_files (
|
|
id TEXT PRIMARY KEY,
|
|
filename TEXT NOT NULL,
|
|
original_name TEXT NOT NULL,
|
|
uploaded_at TEXT NOT NULL,
|
|
size_bytes INTEGER NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
message_count INTEGER DEFAULT 0,
|
|
error TEXT,
|
|
uploaded_by TEXT,
|
|
folder_id TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
pst_id TEXT NOT NULL,
|
|
folder TEXT,
|
|
subject TEXT,
|
|
sender TEXT,
|
|
recipients TEXT,
|
|
sent_date TEXT,
|
|
body TEXT,
|
|
attachments TEXT,
|
|
FOREIGN KEY (pst_id) REFERENCES pst_files(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_messages_pst ON messages(pst_id);
|
|
CREATE INDEX IF NOT EXISTS idx_messages_sent_date ON messages(sent_date);
|
|
CREATE INDEX IF NOT EXISTS idx_pst_files_folder ON pst_files(folder_id);
|
|
|
|
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
subject, sender, recipients, body, folder,
|
|
content='messages',
|
|
content_rowid='id',
|
|
tokenize='porter unicode61'
|
|
);
|
|
|
|
CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
|
|
INSERT INTO messages_fts(rowid, subject, sender, recipients, body, folder)
|
|
VALUES (new.id, new.subject, new.sender, new.recipients, new.body, new.folder);
|
|
END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
|
|
INSERT INTO messages_fts(messages_fts, rowid, subject, sender, recipients, body, folder)
|
|
VALUES ('delete', old.id, old.subject, old.sender, old.recipients, old.body, old.folder);
|
|
END;
|
|
""")
|
|
# Backfill: add folder_id column to DBs created before this feature existed.
|
|
cols = [r["name"] for r in conn.execute("PRAGMA table_info(pst_files)").fetchall()]
|
|
if "folder_id" not in cols:
|
|
conn.execute("ALTER TABLE pst_files ADD COLUMN folder_id TEXT")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_pst_files_folder ON pst_files(folder_id)")
|
|
conn.commit()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Access control helpers — which folders / PSTs is a given user allowed to see?
|
|
#
|
|
# Rules:
|
|
# - Admins see everything.
|
|
# - Regular users see only folders they have been explicitly granted,
|
|
# plus the implicit "Uncategorized" bucket (folder_id = NULL) — this is
|
|
# deliberately shared because otherwise newly-uploaded PSTs would be
|
|
# invisible until someone moved them into a permitted folder.
|
|
# - A PST is visible iff its folder is visible (or the PST is uncategorized).
|
|
# ---------------------------------------------------------------------------
|
|
def allowed_folder_ids_for(user) -> set[str]:
|
|
"""Return the set of folder_ids a user is explicitly permitted on.
|
|
For admins this is not used — they see everything unconditionally."""
|
|
with get_db() as conn:
|
|
rows = conn.execute(
|
|
"SELECT folder_id FROM folder_permissions WHERE user_id = ?",
|
|
(user.id,),
|
|
).fetchall()
|
|
return {r["folder_id"] for r in rows}
|
|
|
|
|
|
def user_can_see_folder(user, folder_id: Optional[str]) -> bool:
|
|
"""Can this user see content inside this folder? folder_id may be None
|
|
(Uncategorized), which is visible to everyone."""
|
|
if user.is_admin:
|
|
return True
|
|
if folder_id is None:
|
|
return True # Uncategorized is shared
|
|
return folder_id in allowed_folder_ids_for(user)
|
|
|
|
|
|
def user_can_see_pst(user, pst_id: str) -> bool:
|
|
if user.is_admin:
|
|
return True
|
|
with get_db() as conn:
|
|
row = conn.execute(
|
|
"SELECT folder_id FROM pst_files WHERE id = ?", (pst_id,)
|
|
).fetchone()
|
|
if not row:
|
|
return False
|
|
return user_can_see_folder(user, row["folder_id"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PST indexing helpers (unchanged from pre-auth version)
|
|
# ---------------------------------------------------------------------------
|
|
def _safe_str(value) -> str:
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, bytes):
|
|
try:
|
|
return value.decode("utf-8", errors="replace")
|
|
except Exception:
|
|
return value.decode("latin-1", errors="replace")
|
|
return str(value)
|
|
|
|
|
|
def _extract_recipients(message) -> str:
|
|
parts = []
|
|
try:
|
|
headers = _safe_str(getattr(message, "transport_headers", ""))
|
|
if headers:
|
|
for line in headers.split("\n"):
|
|
low = line.lower()
|
|
if low.startswith("to:") or low.startswith("cc:") or low.startswith("bcc:"):
|
|
parts.append(line.strip())
|
|
except Exception:
|
|
pass
|
|
return " | ".join(parts) if parts else ""
|
|
|
|
|
|
def _extract_body(message) -> str:
|
|
for attr in ("plain_text_body", "html_body", "rtf_body"):
|
|
try:
|
|
body = getattr(message, attr, None)
|
|
if body:
|
|
text = _safe_str(body)
|
|
if attr == "html_body":
|
|
text = re.sub(r"<[^>]+>", " ", text)
|
|
text = re.sub(r"\s+", " ", text).strip()
|
|
if text:
|
|
return text
|
|
except Exception:
|
|
continue
|
|
return ""
|
|
|
|
|
|
def _extract_sender(message) -> str:
|
|
for attr in ("sender_name", "sender_email_address"):
|
|
try:
|
|
v = _safe_str(getattr(message, attr, ""))
|
|
if v:
|
|
return v
|
|
except Exception:
|
|
continue
|
|
try:
|
|
headers = _safe_str(getattr(message, "transport_headers", ""))
|
|
for line in headers.split("\n"):
|
|
if line.lower().startswith("from:"):
|
|
return line.split(":", 1)[1].strip()
|
|
except Exception:
|
|
pass
|
|
return ""
|
|
|
|
|
|
def _extract_date(message) -> Optional[str]:
|
|
try:
|
|
d = getattr(message, "delivery_time", None) or getattr(message, "client_submit_time", None)
|
|
if d:
|
|
if isinstance(d, datetime):
|
|
return d.isoformat()
|
|
return _safe_str(d)
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _extract_attachments(message) -> str:
|
|
names = []
|
|
try:
|
|
count = getattr(message, "number_of_attachments", 0)
|
|
for i in range(count):
|
|
try:
|
|
att = message.get_attachment(i)
|
|
name = _safe_str(getattr(att, "name", "") or getattr(att, "long_filename", ""))
|
|
if name:
|
|
names.append(name)
|
|
except Exception:
|
|
continue
|
|
except Exception:
|
|
pass
|
|
return " | ".join(names)
|
|
|
|
|
|
def _walk_folders(folder, path=""):
|
|
try:
|
|
name = _safe_str(getattr(folder, "name", "") or "")
|
|
except Exception:
|
|
name = ""
|
|
current_path = f"{path}/{name}" if name else (path or "/")
|
|
try:
|
|
for i in range(folder.number_of_sub_messages):
|
|
try:
|
|
msg = folder.get_sub_message(i)
|
|
yield current_path, msg
|
|
except Exception:
|
|
continue
|
|
except Exception:
|
|
pass
|
|
try:
|
|
for i in range(folder.number_of_sub_folders):
|
|
try:
|
|
sub = folder.get_sub_folder(i)
|
|
yield from _walk_folders(sub, current_path)
|
|
except Exception:
|
|
continue
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def index_pst_file(pst_id: str, file_path: Path):
|
|
pff = None
|
|
try:
|
|
pff = pypff.file()
|
|
pff.open(str(file_path))
|
|
root = pff.get_root_folder()
|
|
count = 0
|
|
batch = []
|
|
BATCH_SIZE = 500
|
|
|
|
with get_db() as conn:
|
|
for folder_path, msg in _walk_folders(root):
|
|
try:
|
|
batch.append((
|
|
pst_id, folder_path,
|
|
_safe_str(getattr(msg, "subject", "")),
|
|
_extract_sender(msg),
|
|
_extract_recipients(msg),
|
|
_extract_date(msg),
|
|
_extract_body(msg),
|
|
_extract_attachments(msg),
|
|
))
|
|
count += 1
|
|
if len(batch) >= BATCH_SIZE:
|
|
conn.executemany(
|
|
"""INSERT INTO messages
|
|
(pst_id, folder, subject, sender, recipients, sent_date, body, attachments)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
batch,
|
|
)
|
|
conn.commit()
|
|
batch.clear()
|
|
except Exception as e:
|
|
print(f"[index] skip: {e}")
|
|
continue
|
|
|
|
if batch:
|
|
conn.executemany(
|
|
"""INSERT INTO messages
|
|
(pst_id, folder, subject, sender, recipients, sent_date, body, attachments)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
batch,
|
|
)
|
|
conn.commit()
|
|
|
|
conn.execute(
|
|
"UPDATE pst_files SET status = ?, message_count = ? WHERE id = ?",
|
|
("ready", count, pst_id),
|
|
)
|
|
conn.commit()
|
|
|
|
print(f"[index] {pst_id}: {count} messages")
|
|
except Exception as e:
|
|
print(f"[index] failed: {e}")
|
|
with get_db() as conn:
|
|
conn.execute(
|
|
"UPDATE pst_files SET status = ?, error = ? WHERE id = ?",
|
|
("failed", str(e), pst_id),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
if pff is not None:
|
|
try:
|
|
pff.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# FastAPI
|
|
# ---------------------------------------------------------------------------
|
|
app = FastAPI(title="PST Indexer")
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=[],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
@app.on_event("startup")
|
|
def _startup():
|
|
auth.init_auth_schema()
|
|
init_app_schema()
|
|
|
|
|
|
def _set_session_cookie(response: Response, token: str):
|
|
response.set_cookie(
|
|
key=SESSION_COOKIE_NAME,
|
|
value=token,
|
|
max_age=SESSION_TTL_SECONDS,
|
|
httponly=True,
|
|
secure=COOKIE_SECURE,
|
|
samesite="lax",
|
|
path="/",
|
|
)
|
|
|
|
|
|
def _clear_session_cookie(response: Response):
|
|
response.delete_cookie(key=SESSION_COOKIE_NAME, path="/")
|
|
|
|
|
|
# ----- Setup / auth endpoints -----
|
|
|
|
class SetupRequest(BaseModel):
|
|
username: str
|
|
password: str
|
|
|
|
|
|
class LoginRequest(BaseModel):
|
|
username: str
|
|
password: str
|
|
totp_code: Optional[str] = None
|
|
|
|
|
|
@app.get("/api/setup/status")
|
|
def setup_status():
|
|
"""Lets the frontend decide whether to show the setup wizard."""
|
|
return {"needs_setup": count_users() == 0}
|
|
|
|
|
|
@app.post("/api/setup")
|
|
def setup_admin(payload: SetupRequest, response: Response):
|
|
if count_users() > 0:
|
|
raise HTTPException(status.HTTP_409_CONFLICT, "Setup has already been completed")
|
|
|
|
try:
|
|
user = create_user(payload.username, payload.password, role="admin")
|
|
except ValueError as e:
|
|
raise HTTPException(400, str(e))
|
|
|
|
record_login(user.id)
|
|
token = create_session_token(user, scope="app")
|
|
_set_session_cookie(response, token)
|
|
return {"user": user.public_dict()}
|
|
|
|
|
|
@app.post("/api/login")
|
|
def login(payload: LoginRequest, request: Request, response: Response):
|
|
ip = request.client.host if request.client else "unknown"
|
|
rate_key = f"{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, stored_pw_hash = found
|
|
if not verify_password(stored_pw_hash, payload.password):
|
|
login_limiter.record(rate_key)
|
|
raise HTTPException(401, "Invalid credentials")
|
|
|
|
if user.mfa_enabled:
|
|
if not payload.totp_code:
|
|
# Tell the client MFA is required
|
|
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="app")
|
|
_set_session_cookie(response, token)
|
|
return {"user": user.public_dict()}
|
|
|
|
|
|
@app.post("/api/logout")
|
|
def logout(response: Response):
|
|
_clear_session_cookie(response)
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/me")
|
|
def me(user: User = Depends(require_app_user)):
|
|
return {"user": user.public_dict()}
|
|
|
|
|
|
# ----- PST endpoints (all require auth) -----
|
|
|
|
@app.get("/api/health")
|
|
def health():
|
|
return {"status": "ok"}
|
|
|
|
|
|
@app.get("/api/pst-files")
|
|
def list_pst_files(user: User = Depends(require_app_user)):
|
|
with get_db() as conn:
|
|
if user.is_admin:
|
|
rows = conn.execute(
|
|
"""SELECT id, original_name, uploaded_at, size_bytes, status,
|
|
message_count, error, folder_id
|
|
FROM pst_files ORDER BY uploaded_at DESC"""
|
|
).fetchall()
|
|
else:
|
|
allowed = allowed_folder_ids_for(user)
|
|
if allowed:
|
|
placeholders = ",".join("?" for _ in allowed)
|
|
rows = conn.execute(
|
|
f"""SELECT id, original_name, uploaded_at, size_bytes, status,
|
|
message_count, error, folder_id
|
|
FROM pst_files
|
|
WHERE folder_id IS NULL OR folder_id IN ({placeholders})
|
|
ORDER BY uploaded_at DESC""",
|
|
list(allowed),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
"""SELECT id, original_name, uploaded_at, size_bytes, status,
|
|
message_count, error, folder_id
|
|
FROM pst_files WHERE folder_id IS NULL
|
|
ORDER BY uploaded_at DESC"""
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
# ----- Folder endpoints (shared with admin panel) -----
|
|
|
|
class FolderCreate(BaseModel):
|
|
name: str
|
|
|
|
|
|
class FolderRename(BaseModel):
|
|
name: str
|
|
|
|
|
|
class FolderAssign(BaseModel):
|
|
folder_id: Optional[str] = None # null = move to uncategorized
|
|
|
|
|
|
def _validate_folder_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 list_folders(user: User = Depends(require_app_user)):
|
|
with get_db() as conn:
|
|
if user.is_admin:
|
|
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()
|
|
else:
|
|
allowed = allowed_folder_ids_for(user)
|
|
if not allowed:
|
|
return []
|
|
placeholders = ",".join("?" for _ in allowed)
|
|
rows = conn.execute(
|
|
f"""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
|
|
WHERE f.id IN ({placeholders})
|
|
GROUP BY f.id
|
|
ORDER BY f.name COLLATE NOCASE""",
|
|
list(allowed),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
@app.post("/api/folders")
|
|
def create_folder(payload: FolderCreate, user: User = Depends(require_app_user)):
|
|
# Only admins can create folders — the admin panel is the authoritative
|
|
# management surface. (We still allow creation from the main app sidebar,
|
|
# but only for admin users.)
|
|
if not user.is_admin:
|
|
raise HTTPException(403, "Only administrators can create folders")
|
|
name = _validate_folder_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(), user.id),
|
|
)
|
|
conn.commit()
|
|
return {"id": folder_id, "name": name, "file_count": 0}
|
|
|
|
|
|
@app.put("/api/folders/{folder_id}")
|
|
def rename_folder(folder_id: str, payload: FolderRename,
|
|
user: User = Depends(require_app_user)):
|
|
if not user.is_admin:
|
|
raise HTTPException(403, "Only administrators can rename folders")
|
|
name = _validate_folder_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 delete_folder(folder_id: str, user: User = Depends(require_app_user)):
|
|
"""Delete a folder. Any PSTs inside it become uncategorized (folder_id = NULL)."""
|
|
if not user.is_admin:
|
|
raise HTTPException(403, "Only administrators can delete folders")
|
|
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 folder_permissions WHERE folder_id = ?",
|
|
(folder_id,))
|
|
conn.execute("DELETE FROM pst_folders WHERE id = ?", (folder_id,))
|
|
conn.commit()
|
|
return {"deleted": folder_id}
|
|
|
|
|
|
@app.put("/api/pst-files/{pst_id}/folder")
|
|
def assign_pst_to_folder(pst_id: str, payload: FolderAssign,
|
|
user: User = Depends(require_app_user)):
|
|
# Check the user can see the PST's current location AND the destination
|
|
if not user_can_see_pst(user, pst_id):
|
|
raise HTTPException(404, "PST not found")
|
|
if payload.folder_id and not user_can_see_folder(user, payload.folder_id):
|
|
raise HTTPException(403, "You do not have access to the target folder")
|
|
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}
|
|
|
|
|
|
@app.post("/api/pst-files")
|
|
async def upload_pst(
|
|
background_tasks: BackgroundTasks,
|
|
file: UploadFile = File(...),
|
|
folder_id: Optional[str] = None,
|
|
user: User = Depends(require_app_user),
|
|
):
|
|
if not file.filename:
|
|
raise HTTPException(400, "No filename provided")
|
|
if not file.filename.lower().endswith((".pst", ".ost")):
|
|
raise HTTPException(400, "Only .pst and .ost files are supported")
|
|
|
|
# Validate folder if provided (and verify user has access to it)
|
|
if folder_id:
|
|
with get_db() as conn:
|
|
exists = conn.execute("SELECT id FROM pst_folders WHERE id = ?",
|
|
(folder_id,)).fetchone()
|
|
if not exists:
|
|
raise HTTPException(400, "Target folder does not exist")
|
|
if not user_can_see_folder(user, folder_id):
|
|
raise HTTPException(403, "You do not have access to the target folder")
|
|
|
|
pst_id = uuid.uuid4().hex
|
|
stored_name = f"{pst_id}_{file.filename}"
|
|
stored_path = UPLOAD_DIR / stored_name
|
|
|
|
size = 0
|
|
async with aiofiles.open(stored_path, "wb") as out:
|
|
while chunk := await file.read(1024 * 1024):
|
|
await out.write(chunk)
|
|
size += len(chunk)
|
|
|
|
with get_db() as conn:
|
|
conn.execute(
|
|
"""INSERT INTO pst_files
|
|
(id, filename, original_name, uploaded_at, size_bytes, status,
|
|
uploaded_by, folder_id)
|
|
VALUES (?, ?, ?, ?, ?, 'indexing', ?, ?)""",
|
|
(pst_id, stored_name, file.filename,
|
|
datetime.now(timezone.utc).isoformat(), size, user.id, folder_id),
|
|
)
|
|
conn.commit()
|
|
|
|
background_tasks.add_task(asyncio.to_thread, index_pst_file, pst_id, stored_path)
|
|
return {"id": pst_id, "original_name": file.filename,
|
|
"status": "indexing", "size_bytes": size, "folder_id": folder_id}
|
|
|
|
|
|
@app.delete("/api/pst-files/{pst_id}")
|
|
def delete_pst(pst_id: str, user: User = Depends(require_app_user)):
|
|
if not user_can_see_pst(user, pst_id):
|
|
raise HTTPException(404, "PST not found")
|
|
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}
|
|
|
|
|
|
@app.get("/api/pst-files/{pst_id}/folders")
|
|
def get_folders(pst_id: str, user: User = Depends(require_app_user)):
|
|
if not user_can_see_pst(user, pst_id):
|
|
raise HTTPException(404, "PST not found")
|
|
with get_db() as conn:
|
|
rows = conn.execute(
|
|
"""SELECT folder, COUNT(*) as count FROM messages
|
|
WHERE pst_id = ? GROUP BY folder ORDER BY folder""",
|
|
(pst_id,),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def _escape_fts(query: str) -> str:
|
|
tokens = []
|
|
for raw in query.split():
|
|
if re.fullmatch(r"[A-Za-z0-9]+", raw):
|
|
tokens.append(f'"{raw}"*')
|
|
else:
|
|
sanitized = raw.replace('"', '""')
|
|
tokens.append(f'"{sanitized}"')
|
|
return " ".join(tokens)
|
|
|
|
|
|
@app.get("/api/pst-files/{pst_id}/messages")
|
|
def search_messages(
|
|
pst_id: str,
|
|
q: Optional[str] = None,
|
|
folder: Optional[str] = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
user: User = Depends(require_app_user),
|
|
):
|
|
if not user_can_see_pst(user, pst_id):
|
|
raise HTTPException(404, "PST not found")
|
|
limit = max(1, min(limit, 200))
|
|
offset = max(0, offset)
|
|
|
|
with get_db() as conn:
|
|
p = conn.execute("SELECT id FROM pst_files WHERE id = ?", (pst_id,)).fetchone()
|
|
if not p:
|
|
raise HTTPException(404, "PST not found")
|
|
|
|
params = [pst_id]
|
|
|
|
if q and q.strip():
|
|
fts_q = _escape_fts(q.strip())
|
|
base = """
|
|
FROM messages m
|
|
JOIN messages_fts f ON f.rowid = m.id
|
|
WHERE m.pst_id = ? AND messages_fts MATCH ?
|
|
"""
|
|
params.append(fts_q)
|
|
if folder:
|
|
base += " AND m.folder = ?"
|
|
params.append(folder)
|
|
|
|
total = conn.execute("SELECT COUNT(*) as c " + base, params).fetchone()["c"]
|
|
|
|
data_sql = (
|
|
"SELECT m.id, m.folder, m.subject, m.sender, m.recipients, m.sent_date, "
|
|
" m.attachments, snippet(messages_fts, 3, '<mark>', '</mark>', '…', 24) AS snippet "
|
|
+ base + " ORDER BY rank LIMIT ? OFFSET ?"
|
|
)
|
|
rows = conn.execute(data_sql, params + [limit, offset]).fetchall()
|
|
else:
|
|
where = ["m.pst_id = ?"]
|
|
if folder:
|
|
where.append("m.folder = ?")
|
|
params.append(folder)
|
|
where_sql = " AND ".join(where)
|
|
total = conn.execute(
|
|
f"SELECT COUNT(*) as c FROM messages m WHERE {where_sql}", params
|
|
).fetchone()["c"]
|
|
rows = conn.execute(
|
|
f"""SELECT m.id, m.folder, m.subject, m.sender, m.recipients,
|
|
m.sent_date, m.attachments, substr(m.body, 1, 200) AS snippet
|
|
FROM messages m WHERE {where_sql}
|
|
ORDER BY m.sent_date DESC LIMIT ? OFFSET ?""",
|
|
params + [limit, offset],
|
|
).fetchall()
|
|
|
|
return {"total": total, "limit": limit, "offset": offset,
|
|
"items": [dict(r) for r in rows]}
|
|
|
|
|
|
@app.get("/api/messages/{message_id}")
|
|
def get_message(message_id: int, user: User = Depends(require_app_user)):
|
|
with get_db() as conn:
|
|
row = conn.execute(
|
|
"""SELECT id, pst_id, folder, subject, sender, recipients,
|
|
sent_date, body, attachments
|
|
FROM messages WHERE id = ?""",
|
|
(message_id,),
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Message not found")
|
|
if not user_can_see_pst(user, row["pst_id"]):
|
|
raise HTTPException(404, "Message not found")
|
|
return dict(row)
|
|
|
|
|
|
# ===========================================================================
|
|
# ADMINISTRATION SECTION
|
|
#
|
|
# Previously a separate FastAPI process (admin_app.py) on its own port. It now
|
|
# runs inside the main application. Every endpoint below is namespaced under
|
|
# /api/admin and protected by `require_app_admin`, which authenticates against
|
|
# the main app session cookie AND requires the admin role. Non-admin users
|
|
# receive 403; unauthenticated requests receive 401.
|
|
#
|
|
# The administration UI is served at /admin (static files), and the main app
|
|
# shows an "ADMINISTRATION" button (admins only) that links to it.
|
|
# ===========================================================================
|
|
|
|
# ----- Admin: 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/admin/me")
|
|
def admin_me(user: User = Depends(require_app_admin)):
|
|
return {"user": user.public_dict()}
|
|
|
|
|
|
@app.get("/api/admin/users")
|
|
def admin_list_users(_: User = Depends(require_app_admin)):
|
|
return [u.public_dict() for u in list_users()]
|
|
|
|
|
|
@app.post("/api/admin/users")
|
|
def admin_create_user(payload: CreateUserPayload,
|
|
admin: User = Depends(require_app_admin)):
|
|
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/admin/users/{user_id}")
|
|
def admin_delete_user(user_id: str, admin: User = Depends(require_app_admin)):
|
|
target = find_user_by_id(user_id)
|
|
if not target:
|
|
raise HTTPException(404, "User not found")
|
|
if target.id == admin.id:
|
|
raise HTTPException(400, "You cannot delete your own account")
|
|
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/admin/users/{user_id}/role")
|
|
def admin_set_role(user_id: str, payload: UpdateRolePayload,
|
|
admin: User = Depends(require_app_admin)):
|
|
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/admin/users/{user_id}/password")
|
|
def admin_update_password(user_id: str, payload: UpdatePasswordPayload,
|
|
admin: User = Depends(require_app_admin)):
|
|
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}
|
|
|
|
|
|
# ----- Admin: MFA management -----
|
|
|
|
class ConfirmMfaPayload(BaseModel):
|
|
totp_code: str
|
|
|
|
|
|
@app.post("/api/admin/users/{user_id}/mfa/begin")
|
|
def admin_begin_mfa(user_id: str, admin: User = Depends(require_app_admin)):
|
|
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/admin/users/{user_id}/mfa/qr",
|
|
}
|
|
|
|
|
|
@app.get("/api/admin/users/{user_id}/mfa/qr")
|
|
def admin_mfa_qr(user_id: str, admin: User = Depends(require_app_admin)):
|
|
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/admin/users/{user_id}/mfa/confirm")
|
|
def admin_confirm_mfa(user_id: str, payload: ConfirmMfaPayload,
|
|
admin: User = Depends(require_app_admin)):
|
|
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/admin/users/{user_id}/mfa/disable")
|
|
def admin_disable_mfa(user_id: str, admin: User = Depends(require_app_admin)):
|
|
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()
|
|
|
|
|
|
# ----- Admin: folder management (sees ALL folders unconditionally) -----
|
|
|
|
class AdminFolderPayload(BaseModel):
|
|
name: str
|
|
|
|
|
|
@app.get("/api/admin/folders")
|
|
def admin_list_all_folders(_: User = Depends(require_app_admin)):
|
|
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/admin/folders")
|
|
def admin_create_folder(payload: AdminFolderPayload,
|
|
admin: User = Depends(require_app_admin)):
|
|
name = _validate_folder_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/admin/folders/{folder_id}")
|
|
def admin_rename_folder(folder_id: str, payload: AdminFolderPayload,
|
|
admin: User = Depends(require_app_admin)):
|
|
name = _validate_folder_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/admin/folders/{folder_id}")
|
|
def admin_delete_folder(folder_id: str, admin: User = Depends(require_app_admin)):
|
|
"""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 folder_permissions WHERE folder_id = ?",
|
|
(folder_id,))
|
|
conn.execute("DELETE FROM pst_folders WHERE id = ?", (folder_id,))
|
|
conn.commit()
|
|
return {"deleted": folder_id}
|
|
|
|
|
|
# ----- Admin: PST file management (sees ALL files unconditionally) -----
|
|
|
|
@app.get("/api/admin/pst-files")
|
|
def admin_list_pst_files(_: User = Depends(require_app_admin)):
|
|
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/admin/pst-files/{pst_id}")
|
|
def admin_delete_pst(pst_id: str, admin: User = Depends(require_app_admin)):
|
|
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/admin/pst-files/{pst_id}/folder")
|
|
def admin_assign_pst_to_folder(pst_id: str, payload: AdminAssignPayload,
|
|
admin: User = Depends(require_app_admin)):
|
|
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}
|
|
|
|
|
|
# ----- Admin: folder access permissions -----
|
|
|
|
class PermissionToggle(BaseModel):
|
|
granted: bool
|
|
|
|
|
|
@app.get("/api/admin/permissions")
|
|
def admin_list_permissions(_: User = Depends(require_app_admin)):
|
|
"""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 = [_row_to_user(u).public_dict() for u in users]
|
|
return {
|
|
"users": user_list,
|
|
"folders": [dict(f) for f in folders],
|
|
"grants": [dict(g) for g in grants],
|
|
}
|
|
|
|
|
|
@app.get("/api/admin/users/{user_id}/folders")
|
|
def admin_user_folders(user_id: str, _: User = Depends(require_app_admin)):
|
|
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/admin/folders/{folder_id}/users")
|
|
def admin_folder_users(folder_id: str, _: User = Depends(require_app_admin)):
|
|
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()
|
|
|
|
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/admin/permissions/{user_id}/{folder_id}")
|
|
def admin_toggle_permission(user_id: str, folder_id: str,
|
|
payload: PermissionToggle,
|
|
admin: User = Depends(require_app_admin)):
|
|
target = find_user_by_id(user_id)
|
|
if not target:
|
|
raise HTTPException(404, "User not found")
|
|
if target.is_admin:
|
|
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 frontends
|
|
#
|
|
# The administration UI is mounted at /admin and MUST be registered before the
|
|
# catch-all "/" mount so that /admin/* resolves to the admin assets. Both are
|
|
# served from the same origin/port, so they share the main session cookie.
|
|
# ---------------------------------------------------------------------------
|
|
if ADMIN_FRONTEND_DIR.exists():
|
|
app.mount("/admin", StaticFiles(directory=str(ADMIN_FRONTEND_DIR), html=True), name="admin")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Static frontend (anonymous access — but index.html enforces auth client-side
|
|
# AND the APIs enforce it server-side, which is what actually matters)
|
|
# ---------------------------------------------------------------------------
|
|
if FRONTEND_DIR.exists():
|
|
app.mount("/", StaticFiles(directory=str(FRONTEND_DIR), html=True), name="frontend")
|