commit 5482731e7883e77fbef1ed0ca4993bcfd6ca0df7 Author: jpmvaz Date: Sun Sep 13 20:07:03 2026 +0100 v_2.2 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d52803e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +data/ +**/__pycache__ +**/*.pyc +.git +.gitignore +*.md +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..425b319 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# Build tools for libpff-python, curl for healthcheck, libjpeg for qrcode PIL +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + libjpeg-dev \ + zlib1g-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY backend/requirements.txt /app/backend/requirements.txt +RUN pip install --upgrade pip && \ + pip install -r /app/backend/requirements.txt + +COPY backend /app/backend +COPY frontend /app/frontend +COPY admin_frontend /app/admin_frontend +COPY entrypoint.sh /app/entrypoint.sh + +RUN chmod +x /app/entrypoint.sh && \ + mkdir -p /app/data + +ENV DATA_DIR=/app/data \ + FRONTEND_DIR=/app/frontend \ + ADMIN_FRONTEND_DIR=/app/admin_frontend \ + PYTHONPATH=/app/backend \ + COOKIE_SECURE=false + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD curl -fsS http://localhost:8000/api/health || exit 1 + +CMD ["/app/entrypoint.sh"] diff --git a/OLD_VERSIONS/v1.0/pst-indexer.zip b/OLD_VERSIONS/v1.0/pst-indexer.zip new file mode 100644 index 0000000..957841b Binary files /dev/null and b/OLD_VERSIONS/v1.0/pst-indexer.zip differ diff --git a/OLD_VERSIONS/v1.2/pst-indexer.zip b/OLD_VERSIONS/v1.2/pst-indexer.zip new file mode 100644 index 0000000..d25e657 Binary files /dev/null and b/OLD_VERSIONS/v1.2/pst-indexer.zip differ diff --git a/OLD_VERSIONS/v1.3/pst-indexer.zip b/OLD_VERSIONS/v1.3/pst-indexer.zip new file mode 100644 index 0000000..3f8a439 Binary files /dev/null and b/OLD_VERSIONS/v1.3/pst-indexer.zip differ diff --git a/OLD_VERSIONS/v1.4/v1.4.zip b/OLD_VERSIONS/v1.4/v1.4.zip new file mode 100644 index 0000000..335f9e5 Binary files /dev/null and b/OLD_VERSIONS/v1.4/v1.4.zip differ diff --git a/OLD_VERSIONS/v2.0/pst-indexer.zip b/OLD_VERSIONS/v2.0/pst-indexer.zip new file mode 100644 index 0000000..b02810f Binary files /dev/null and b/OLD_VERSIONS/v2.0/pst-indexer.zip differ diff --git a/OLD_VERSIONS/v2.0/pst-indexer/.dockerignore b/OLD_VERSIONS/v2.0/pst-indexer/.dockerignore new file mode 100644 index 0000000..d52803e --- /dev/null +++ b/OLD_VERSIONS/v2.0/pst-indexer/.dockerignore @@ -0,0 +1,7 @@ +data/ +**/__pycache__ +**/*.pyc +.git +.gitignore +*.md +.DS_Store diff --git a/OLD_VERSIONS/v2.0/pst-indexer/Dockerfile b/OLD_VERSIONS/v2.0/pst-indexer/Dockerfile new file mode 100644 index 0000000..234c804 --- /dev/null +++ b/OLD_VERSIONS/v2.0/pst-indexer/Dockerfile @@ -0,0 +1,41 @@ +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# Build tools for libpff-python, curl for healthcheck, libjpeg for qrcode PIL +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + libjpeg-dev \ + zlib1g-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY backend/requirements.txt /app/backend/requirements.txt +RUN pip install --upgrade pip && \ + pip install -r /app/backend/requirements.txt + +COPY backend /app/backend +COPY frontend /app/frontend +COPY admin_frontend /app/admin_frontend +COPY entrypoint.sh /app/entrypoint.sh + +RUN chmod +x /app/entrypoint.sh && \ + mkdir -p /app/data + +ENV DATA_DIR=/app/data \ + FRONTEND_DIR=/app/frontend \ + ADMIN_FRONTEND_DIR=/app/admin_frontend \ + PYTHONPATH=/app/backend \ + COOKIE_SECURE=false + +EXPOSE 8000 8001 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD curl -fsS http://localhost:8000/api/health && curl -fsS http://localhost:8001/api/health || exit 1 + +CMD ["/app/entrypoint.sh"] diff --git a/OLD_VERSIONS/v2.0/pst-indexer/README.md b/OLD_VERSIONS/v2.0/pst-indexer/README.md new file mode 100644 index 0000000..657990c --- /dev/null +++ b/OLD_VERSIONS/v2.0/pst-indexer/README.md @@ -0,0 +1,1098 @@ +# PST Archive — Mail Indexer + +A self-hosted, Dockerized application for **uploading Microsoft Outlook PST/OST files**, **indexing every email inside them**, and **searching the contents through a web browser** — with full **user authentication**, **multi-factor authentication (MFA)**, **encrypted-at-rest credentials**, and a **separate admin panel** on its own port. + +Designed for **forensic review, e-discovery, personal archive browsing, and migration audits** — any situation where you need to crack open a PST and actually find things inside it without loading it into Outlook. + +--- + +## Table of Contents + +1. [Features](#features) +2. [Quick Start](#quick-start) +3. [First-Run Setup](#first-run-setup) +4. [Running as a Daemon](#running-as-a-daemon) +5. [Theming & Dark Mode](#theming--dark-mode) +6. [Authentication & Security Model](#authentication--security-model) +7. [Admin Panel](#admin-panel) +8. [Organizing PSTs with Folders](#organizing-psts-with-folders) +9. [Folder Access Permissions](#folder-access-permissions) +10. [Multi-Factor Authentication (MFA)](#multi-factor-authentication-mfa) +11. [Encryption at Rest](#encryption-at-rest) +12. [How It Works](#how-it-works) +13. [Architecture](#architecture) +14. [Project Layout](#project-layout) +15. [Data Model](#data-model) +16. [REST API Reference](#rest-api-reference) +17. [Frontend Interfaces](#frontend-interfaces) +18. [Search Syntax](#search-syntax) +19. [Configuration](#configuration) +20. [Persistence & Backup](#persistence--backup) +21. [Troubleshooting](#troubleshooting) +22. [Performance Notes](#performance-notes) +23. [Security Considerations](#security-considerations) +24. [Extending the App](#extending-the-app) + +--- + +## Features + +### Core +- **Drag-and-drop upload** of `.pst` and `.ost` files through the browser +- **Streaming upload with progress bar** — handles multi-gigabyte archives +- **Background indexing** into SQLite with **FTS5 full-text search** (ranked, prefix-matched, with highlighted snippets) +- **Multi-archive support**: switch between uploaded PSTs; each is searched independently +- **Folder organization**: create folders in the sidebar to group related PST files (e.g. by client, case, year). Move archives between folders, rename folders, or delete folders (any archives inside are safely moved to Uncategorized) +- **Per-folder access permissions**: admins can restrict which non-admin users can view each folder. A single user can be granted access to many folders; a single folder can be shared with many users (true many-to-many) +- **Folder filter**: narrow search to any folder within the archive (Inbox, Sent Items, nested folders) +- **Full message viewer**: subject, sender, recipients, date, folder, attachments, body +- **Persistent storage**: everything lives in a mounted `./data` volume + +### Security +- **First-run setup wizard** forces creation of an initial administrator before any other action is possible +- **Password authentication** using **Argon2id** (winner of the Password Hashing Competition) with constant-time verification +- **TOTP-based MFA** (RFC 6238; compatible with Google Authenticator, Authy, 1Password, Microsoft Authenticator, and every other standard authenticator app) +- **Encryption at rest** for all user PII — usernames, password hashes, and TOTP secrets are stored as Fernet (AES-128-CBC + HMAC-SHA256) ciphertexts +- **Master key** auto-generated on first run and stored with `chmod 600`, or overridable via `MASTER_SECRET` environment variable +- **HMAC-based username lookup** — usernames are never stored in plaintext but are still indexable for login +- **JWT session cookies**, signed with an HKDF-derived sub-key, HttpOnly, SameSite=Lax +- **Rate limiting** on login attempts (10 failures per 5 minutes per IP+username) +- **Admin-only user management**: create, delete, role-change, password-reset, MFA-enroll other users +- **Admin file & folder management**: system-wide view of every uploaded PST and every folder, with create/rename/delete for folders and force-delete for any file regardless of uploader +- **Separate admin panel on a separate port** — lockable behind its own firewall rule or VPN + +### UI +- **Light and dark themes** on both the main app and the admin panel, with a sun/moon toggle in the masthead and on login/setup screens +- Preference persists in `localStorage` per-browser and respects the operating system's `prefers-color-scheme` on first visit +- Theme applied inline in `` before stylesheets load — **no flash of wrong theme** on page load +- Dark mode uses a custom "inked paper" palette (deep charcoal with warm paper-cream text and softened oxblood accent) — intentional character, not generic-dark +- Editorial typography (Fraunces serif + JetBrains Mono), grain overlay, responsive layout down to mobile widths + +--- + +## Quick Start + +### Prerequisites + +- Docker 20.10 or newer +- Docker Compose v2 +- A `.pst` or `.ost` file to index + +### Run (as a daemon — recommended) + +```bash +docker compose up -d --build +``` + +The `-d` flag runs the container in the background (detached). On the first run this takes **3–5 minutes** because `libpff-python` compiles a C extension from source; subsequent startups are nearly instant. + +The container is configured with `restart: unless-stopped`, so it will automatically come back up after a reboot or a crash, and keep running across Docker daemon restarts — until you explicitly stop it. + +Once the container is up: + +- **Main app**: `http://localhost:8000` +- **Admin panel**: `http://localhost:8001` + +### Foreground mode (useful for debugging) + +```bash +docker compose up --build +``` + +This runs the container attached to your terminal so you can see logs inline. Press `Ctrl-C` to stop. + +### Tail the logs + +```bash +docker compose logs -f # follow both streams +docker compose logs -f pst-indexer # explicit service name +``` + +### Check status + +```bash +docker compose ps # is it running? +docker compose top # which processes? +``` + +### Stop + +```bash +docker compose stop # stop, keep data, keep container +docker compose down # stop and remove the container, keep data volume +``` + +### Full reset + +```bash +docker compose down +rm -rf ./data # erases everything including users and the master key +docker compose up -d --build +``` + +### Restart / update + +```bash +# After editing code or pulling new images: +docker compose up -d --build # rebuild & restart with one command +# Or just restart without rebuilding: +docker compose restart +``` + +--- + +## First-Run Setup + +On the very first visit to `http://localhost:8000`, you will be presented with a **setup wizard** that asks you to create the initial administrator: + +1. Choose a username (3–64 characters) +2. Choose a password (8+ characters — use a passphrase you'll remember) +3. Confirm the password + +The setup endpoint is only callable **while the users table is empty**. Once the first admin is created, the endpoint returns `409 Conflict` and is effectively disabled. + +After setup, you are automatically signed in and landed on the main app. + +**Heads up:** the admin panel on port 8001 will refuse to accept any logins until the first admin has been created via the main app's setup wizard. This prevents a race where someone could hit `/api/login` on the admin port before you've set things up. + +--- + +## Running as a Daemon + +By default, this app is designed to **run as a long-lived background service**. The `docker-compose.yml` sets `restart: unless-stopped`, which means once started, the container will: + +- Keep running after you close your terminal +- Automatically restart if the process crashes +- Automatically restart when the host reboots and Docker comes back up +- **Not** restart if you explicitly stop it with `docker compose stop` (hence "unless-stopped") + +### Starting the daemon + +```bash +docker compose up -d --build +``` + +The `-d` flag is short for "detached" — the container is launched in the background and the command returns immediately. Your terminal is free to do other things; the container keeps running. + +### Verifying it's running + +```bash +docker compose ps +``` + +You should see output like: + +``` +NAME IMAGE STATUS PORTS +pst-indexer pst-indexer-pst-indexer Up 2 minutes (healthy) 0.0.0.0:8000->8000/tcp, 0.0.0.0:8001->8001/tcp +``` + +The `(healthy)` flag comes from the built-in healthcheck, which probes both `/api/health` endpoints every 30 seconds. + +### Watching logs + +Since the container is detached, you won't see logs in your terminal by default. Attach to them on demand: + +```bash +docker compose logs # print recent logs and exit +docker compose logs -f # follow (live tail), Ctrl-C to detach +docker compose logs -f --tail=100 # follow, starting from the last 100 lines +docker compose logs --since 1h # only logs from the last hour +``` + +Detaching from `-f` does **not** stop the container — it just stops showing you the logs. + +### Stopping + +```bash +docker compose stop # stop the container but keep it around +docker compose start # start it again if previously stopped +docker compose restart # stop and start in one command +docker compose down # stop and remove the container (data volume is kept) +docker compose down -v # stop, remove container, AND remove anonymous volumes +``` + +None of these delete your `./data` directory — that's a bind mount, not a Docker-managed volume, so it's always yours. + +### Survives reboots + +Because `restart: unless-stopped` is set, after a host reboot Docker will automatically start the container as soon as the Docker daemon comes up. Nothing manual required on your part. + +If you ever explicitly stopped it with `docker compose stop`, Docker will *not* auto-start it after reboot (that's the whole point of "unless-stopped"). Run `docker compose start` or `docker compose up -d` to bring it back. + +### Updating to a new version + +```bash +# Pull new source, then: +docker compose up -d --build +``` + +Docker Compose compares the current image to the built one; if they differ, it rebuilds and recreates the container with zero-downtime-style replacement. Your `./data` volume is preserved throughout. + +--- + +## Theming & Dark Mode + +Both the main app and the admin panel support **light and dark themes**, with a sun/moon toggle visible in the masthead (when signed in) and floating in the top-right corner on the login/setup screens. + +### How the toggle works + +- Click the icon to flip between themes +- Choice is saved to `localStorage["pst-theme"]` in your browser +- On a fresh browser (nothing saved), the app respects your operating system's `prefers-color-scheme` setting +- The theme is applied by an inline ` + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/OLD_VERSIONS/v2.0/pst-indexer/backend/admin_app.py b/OLD_VERSIONS/v2.0/pst-indexer/backend/admin_app.py new file mode 100644 index 0000000..e79bf8f --- /dev/null +++ b/OLD_VERSIONS/v2.0/pst-indexer/backend/admin_app.py @@ -0,0 +1,587 @@ +""" +PST Indexer — Admin Panel (port 8001). + +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") diff --git a/OLD_VERSIONS/v2.0/pst-indexer/backend/app.py b/OLD_VERSIONS/v2.0/pst-indexer/backend/app.py new file mode 100644 index 0000000..f462edf --- /dev/null +++ b/OLD_VERSIONS/v2.0/pst-indexer/backend/app.py @@ -0,0 +1,835 @@ +""" +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 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 +from fastapi import ( + BackgroundTasks, Depends, FastAPI, File, HTTPException, + Request, Response, UploadFile, status, +) +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse, FileResponse +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, + get_db, +) + +# --------------------------------------------------------------------------- +# 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")) + +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, '', '', '…', 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) + + +# --------------------------------------------------------------------------- +# 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") diff --git a/OLD_VERSIONS/v2.0/pst-indexer/backend/auth.py b/OLD_VERSIONS/v2.0/pst-indexer/backend/auth.py new file mode 100644 index 0000000..eedd118 --- /dev/null +++ b/OLD_VERSIONS/v2.0/pst-indexer/backend/auth.py @@ -0,0 +1,513 @@ +""" +auth.py — Shared authentication & encryption module. + +Responsibilities: +- Derive or generate a master secret key (persisted to disk with chmod 600) +- Provide Fernet-based encryption for user PII at rest +- Argon2id password hashing (constant-time verification) +- TOTP secret generation, QR provisioning URI, 6-digit code verification +- JWT session tokens (signed with master key) +- User CRUD on the shared SQLite database +- FastAPI dependencies to enforce authentication and admin role +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import os +import secrets +import sqlite3 +import time +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Optional + +import jwt +import pyotp +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError, InvalidHashError +from cryptography.fernet import Fernet, InvalidToken +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from fastapi import Depends, HTTPException, Request, status + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +DATA_DIR = Path(os.environ.get("DATA_DIR", "/app/data")) +DATA_DIR.mkdir(parents=True, exist_ok=True) + +SECRET_KEY_PATH = DATA_DIR / "secret.key" +DB_PATH = DATA_DIR / "pst_index.db" + +SESSION_TTL_SECONDS = int(os.environ.get("SESSION_TTL_SECONDS", 60 * 60 * 8)) # 8 hours +SESSION_COOKIE_NAME = "pst_session" +ADMIN_SESSION_COOKIE_NAME = "pst_admin_session" + +JWT_ALGORITHM = "HS256" + +# --------------------------------------------------------------------------- +# Master secret +# --------------------------------------------------------------------------- +def _load_or_create_master_secret() -> bytes: + """Return a 32-byte master secret. Created on first run, persisted to disk.""" + # Allow overriding via env var (useful for orchestration); still persist so + # restarts without the env var keep working. + env_secret = os.environ.get("MASTER_SECRET") + if env_secret: + return hashlib.sha256(env_secret.encode("utf-8")).digest() + + if SECRET_KEY_PATH.exists(): + data = SECRET_KEY_PATH.read_bytes() + if len(data) == 32: + return data + # Legacy or truncated: rewrap + return hashlib.sha256(data).digest() + + secret = secrets.token_bytes(32) + SECRET_KEY_PATH.write_bytes(secret) + try: + os.chmod(SECRET_KEY_PATH, 0o600) + except Exception: + pass + return secret + + +MASTER_SECRET: bytes = _load_or_create_master_secret() + + +def _derive_subkey(info: bytes, length: int = 32) -> bytes: + """Derive a sub-key from the master secret using HKDF-SHA256.""" + return HKDF( + algorithm=hashes.SHA256(), + length=length, + salt=b"pst-indexer-salt-v1", + info=info, + ).derive(MASTER_SECRET) + + +# Key used for encrypting user PII at rest +_FERNET_KEY = base64.urlsafe_b64encode(_derive_subkey(b"fernet-user-pii")) +_FERNET = Fernet(_FERNET_KEY) + +# Key used for JWT signatures +_JWT_KEY = _derive_subkey(b"jwt-sessions") + +# Key used for deterministic username lookup hashes (so we can index without +# exposing plaintext usernames in the DB). +_USERNAME_HMAC_KEY = _derive_subkey(b"username-lookup") + + +# --------------------------------------------------------------------------- +# Encryption helpers +# --------------------------------------------------------------------------- +def encrypt(plaintext: str) -> str: + """Fernet-encrypt a string; returns base64 ciphertext.""" + if plaintext is None: + return "" + return _FERNET.encrypt(plaintext.encode("utf-8")).decode("utf-8") + + +def decrypt(ciphertext: str) -> str: + """Fernet-decrypt. Returns empty string for empty input.""" + if not ciphertext: + return "" + try: + return _FERNET.decrypt(ciphertext.encode("utf-8")).decode("utf-8") + except InvalidToken: + raise ValueError("Failed to decrypt — master key may have changed") + + +def username_lookup_hash(username: str) -> str: + """Deterministic HMAC-SHA256 over the normalized username, hex-encoded.""" + normalized = username.strip().lower().encode("utf-8") + return hmac.new(_USERNAME_HMAC_KEY, normalized, hashlib.sha256).hexdigest() + + +# --------------------------------------------------------------------------- +# Password hashing +# --------------------------------------------------------------------------- +_hasher = PasswordHasher( + time_cost=3, + memory_cost=64 * 1024, # 64 MiB + parallelism=2, +) + + +def hash_password(password: str) -> str: + return _hasher.hash(password) + + +def verify_password(stored_hash: str, password: str) -> bool: + try: + _hasher.verify(stored_hash, password) + return True + except (VerifyMismatchError, InvalidHashError): + return False + except Exception: + return False + + +# --------------------------------------------------------------------------- +# TOTP (MFA) +# --------------------------------------------------------------------------- +def generate_totp_secret() -> str: + return pyotp.random_base32() + + +def totp_provisioning_uri(secret: str, username: str, issuer: str = "PST Archive") -> str: + return pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=issuer) + + +def verify_totp(secret: str, code: str) -> bool: + if not secret or not code: + return False + try: + return pyotp.TOTP(secret).verify(code.strip(), valid_window=1) + except Exception: + return False + + +# --------------------------------------------------------------------------- +# Database +# --------------------------------------------------------------------------- +@contextmanager +def get_db(): + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + try: + yield conn + finally: + conn.close() + + +def init_auth_schema(): + with get_db() as conn: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username_hash TEXT NOT NULL UNIQUE, + username_enc TEXT NOT NULL, + password_hash_enc TEXT NOT NULL, + totp_secret_enc TEXT, + mfa_enabled INTEGER NOT NULL DEFAULT 0, + role TEXT NOT NULL DEFAULT 'user', + created_at TEXT NOT NULL, + last_login TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_users_username_hash ON users(username_hash); + CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); + """) + conn.commit() + + +# --------------------------------------------------------------------------- +# User model +# --------------------------------------------------------------------------- +@dataclass +class User: + id: str + username: str + role: str + mfa_enabled: bool + created_at: str + last_login: Optional[str] + + @property + def is_admin(self) -> bool: + return self.role == "admin" + + def public_dict(self) -> dict: + return { + "id": self.id, + "username": self.username, + "role": self.role, + "mfa_enabled": self.mfa_enabled, + "created_at": self.created_at, + "last_login": self.last_login, + } + + +def _row_to_user(row: sqlite3.Row) -> User: + return User( + id=row["id"], + username=decrypt(row["username_enc"]), + role=row["role"], + mfa_enabled=bool(row["mfa_enabled"]), + created_at=row["created_at"], + last_login=row["last_login"], + ) + + +def count_users() -> int: + with get_db() as conn: + return conn.execute("SELECT COUNT(*) AS c FROM users").fetchone()["c"] + + +def count_admins() -> int: + with get_db() as conn: + return conn.execute("SELECT COUNT(*) AS c FROM users WHERE role='admin'").fetchone()["c"] + + +def find_user_by_username(username: str) -> Optional[tuple[User, str]]: + """Return (User, decrypted_password_hash) if found, else None.""" + uh = username_lookup_hash(username) + with get_db() as conn: + row = conn.execute( + "SELECT * FROM users WHERE username_hash = ?", (uh,) + ).fetchone() + if not row: + return None + user = _row_to_user(row) + try: + pw_hash = decrypt(row["password_hash_enc"]) + except ValueError: + return None + return user, pw_hash + + +def find_user_by_id(user_id: str) -> Optional[User]: + with get_db() as conn: + row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + if not row: + return None + return _row_to_user(row) + + +def get_totp_secret(user_id: str) -> Optional[str]: + with get_db() as conn: + row = conn.execute( + "SELECT totp_secret_enc FROM users WHERE id = ?", (user_id,) + ).fetchone() + if not row or not row["totp_secret_enc"]: + return None + try: + return decrypt(row["totp_secret_enc"]) + except ValueError: + return None + + +def list_users() -> list[User]: + with get_db() as conn: + rows = conn.execute( + "SELECT * FROM users ORDER BY created_at ASC" + ).fetchall() + return [_row_to_user(r) for r in rows] + + +def create_user(username: str, password: str, role: str = "user") -> User: + username = username.strip() + if not username or len(username) < 3: + raise ValueError("Username must be at least 3 characters") + if len(username) > 64: + raise ValueError("Username too long") + if role not in ("user", "admin"): + raise ValueError("Invalid role") + if len(password) < 8: + raise ValueError("Password must be at least 8 characters") + + uh = username_lookup_hash(username) + with get_db() as conn: + existing = conn.execute( + "SELECT id FROM users WHERE username_hash = ?", (uh,) + ).fetchone() + if existing: + raise ValueError("Username already taken") + + user_id = secrets.token_hex(16) + now = datetime.now(timezone.utc).isoformat() + pw_hash = hash_password(password) + + conn.execute( + """INSERT INTO users + (id, username_hash, username_enc, password_hash_enc, + totp_secret_enc, mfa_enabled, role, created_at, last_login) + VALUES (?, ?, ?, ?, NULL, 0, ?, ?, NULL)""", + (user_id, uh, encrypt(username), encrypt(pw_hash), role, now), + ) + conn.commit() + + return User( + id=user_id, username=username, role=role, mfa_enabled=False, + created_at=now, last_login=None, + ) + + +def delete_user(user_id: str) -> None: + with get_db() as conn: + # Also clean up any folder permission grants this user had + conn.execute("DELETE FROM folder_permissions WHERE user_id = ?", (user_id,)) + conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) + conn.commit() + + +def set_user_role(user_id: str, role: str) -> None: + if role not in ("user", "admin"): + raise ValueError("Invalid role") + with get_db() as conn: + conn.execute("UPDATE users SET role = ? WHERE id = ?", (role, user_id)) + conn.commit() + + +def update_password(user_id: str, new_password: str) -> None: + if len(new_password) < 8: + raise ValueError("Password must be at least 8 characters") + pw_hash = hash_password(new_password) + with get_db() as conn: + conn.execute( + "UPDATE users SET password_hash_enc = ? WHERE id = ?", + (encrypt(pw_hash), user_id), + ) + conn.commit() + + +def begin_mfa_enrollment(user_id: str) -> tuple[str, str]: + """Generate a new TOTP secret (not yet enabled) and return (secret, provisioning_uri).""" + user = find_user_by_id(user_id) + if not user: + raise ValueError("User not found") + secret = generate_totp_secret() + # Stash it but leave mfa_enabled = 0 until confirmed + with get_db() as conn: + conn.execute( + "UPDATE users SET totp_secret_enc = ?, mfa_enabled = 0 WHERE id = ?", + (encrypt(secret), user_id), + ) + conn.commit() + uri = totp_provisioning_uri(secret, user.username) + return secret, uri + + +def confirm_mfa_enrollment(user_id: str, code: str) -> bool: + secret = get_totp_secret(user_id) + if not secret or not verify_totp(secret, code): + return False + with get_db() as conn: + conn.execute("UPDATE users SET mfa_enabled = 1 WHERE id = ?", (user_id,)) + conn.commit() + return True + + +def disable_mfa(user_id: str) -> None: + with get_db() as conn: + conn.execute( + "UPDATE users SET mfa_enabled = 0, totp_secret_enc = NULL WHERE id = ?", + (user_id,), + ) + conn.commit() + + +def record_login(user_id: str) -> None: + with get_db() as conn: + conn.execute( + "UPDATE users SET last_login = ? WHERE id = ?", + (datetime.now(timezone.utc).isoformat(), user_id), + ) + conn.commit() + + +# --------------------------------------------------------------------------- +# JWT sessions +# --------------------------------------------------------------------------- +def create_session_token(user: User, scope: str = "app") -> str: + """scope='app' for main app, scope='admin' for admin panel.""" + now = datetime.now(timezone.utc) + payload = { + "sub": user.id, + "role": user.role, + "scope": scope, + "iat": int(now.timestamp()), + "exp": int((now + timedelta(seconds=SESSION_TTL_SECONDS)).timestamp()), + } + return jwt.encode(payload, _JWT_KEY, algorithm=JWT_ALGORITHM) + + +def verify_session_token(token: str, expected_scope: str) -> Optional[dict]: + try: + payload = jwt.decode(token, _JWT_KEY, algorithms=[JWT_ALGORITHM]) + if payload.get("scope") != expected_scope: + return None + return payload + except jwt.PyJWTError: + return None + + +# --------------------------------------------------------------------------- +# FastAPI dependencies +# --------------------------------------------------------------------------- +def _extract_token(request: Request, cookie_name: str) -> Optional[str]: + # Prefer cookie, fall back to Authorization header + token = request.cookies.get(cookie_name) + if token: + return token + auth = request.headers.get("authorization", "") + if auth.lower().startswith("bearer "): + return auth[7:].strip() + return None + + +def require_user(scope: str, cookie_name: str): + def _dep(request: Request) -> User: + token = _extract_token(request, cookie_name) + if not token: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not authenticated") + payload = verify_session_token(token, expected_scope=scope) + if not payload: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid or expired session") + user = find_user_by_id(payload["sub"]) + if not user: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User no longer exists") + return user + return _dep + + +def require_admin(scope: str, cookie_name: str): + base = require_user(scope, cookie_name) + def _dep(request: Request) -> User: + user = base(request) + if not user.is_admin: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin role required") + return user + return _dep + + +# Pre-wired dependencies for the two app scopes +require_app_user = require_user("app", SESSION_COOKIE_NAME) +require_admin_user = require_admin("admin", ADMIN_SESSION_COOKIE_NAME) + + +# --------------------------------------------------------------------------- +# Simple rate limiter (per-IP, per-username) to slow brute-force attempts +# --------------------------------------------------------------------------- +class RateLimiter: + def __init__(self, window_seconds: int = 300, max_attempts: int = 10): + self.window = window_seconds + self.max = max_attempts + self._attempts: dict[str, list[float]] = {} + + def check(self, key: str) -> bool: + now = time.time() + cutoff = now - self.window + arr = [t for t in self._attempts.get(key, []) if t > cutoff] + if len(arr) >= self.max: + self._attempts[key] = arr + return False + return True + + def record(self, key: str) -> None: + now = time.time() + cutoff = now - self.window + arr = [t for t in self._attempts.get(key, []) if t > cutoff] + arr.append(now) + self._attempts[key] = arr + + def reset(self, key: str) -> None: + self._attempts.pop(key, None) + + +login_limiter = RateLimiter(window_seconds=300, max_attempts=10) diff --git a/OLD_VERSIONS/v2.0/pst-indexer/backend/requirements.txt b/OLD_VERSIONS/v2.0/pst-indexer/backend/requirements.txt new file mode 100644 index 0000000..9cbd286 --- /dev/null +++ b/OLD_VERSIONS/v2.0/pst-indexer/backend/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.115.0 +uvicorn[standard]==0.32.0 +python-multipart==0.0.12 +libpff-python==20231205 +aiofiles==24.1.0 +argon2-cffi==23.1.0 +pyotp==2.9.0 +cryptography==43.0.1 +PyJWT==2.9.0 +qrcode[pil]==7.4.2 diff --git a/OLD_VERSIONS/v2.0/pst-indexer/docker-compose.yml b/OLD_VERSIONS/v2.0/pst-indexer/docker-compose.yml new file mode 100644 index 0000000..61385c9 --- /dev/null +++ b/OLD_VERSIONS/v2.0/pst-indexer/docker-compose.yml @@ -0,0 +1,20 @@ +services: + pst-indexer: + build: . + container_name: pst-indexer + ports: + - "8000:8000" # Main app + - "8001:8001" # Admin panel + volumes: + - ./data:/app/data + restart: unless-stopped + environment: + - DATA_DIR=/app/data + # Set to "true" when serving over HTTPS + - COOKIE_SECURE=false + # Session lifetime in seconds (default 8 hours) + - SESSION_TTL_SECONDS=28800 + # Optional: provide your own master secret. If set, this becomes the + # root key for all encryption. If omitted, a random key is generated + # on first run and persisted in ./data/secret.key (chmod 600). + # - MASTER_SECRET=change-me-to-a-long-random-string diff --git a/OLD_VERSIONS/v2.0/pst-indexer/entrypoint.sh b/OLD_VERSIONS/v2.0/pst-indexer/entrypoint.sh new file mode 100644 index 0000000..fb9fe33 --- /dev/null +++ b/OLD_VERSIONS/v2.0/pst-indexer/entrypoint.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Start both the main app (port 8000) and the admin panel (port 8001). +# If either exits, kill the whole container so Docker's restart policy fires. + +set -e + +cleanup() { + echo "Shutting down…" + kill -TERM "$MAIN_PID" "$ADMIN_PID" 2>/dev/null || true + wait + exit 0 +} +trap cleanup SIGINT SIGTERM + +cd /app/backend + +echo "[start] Main app on :8000" +uvicorn app:app --host 0.0.0.0 --port 8000 --proxy-headers & +MAIN_PID=$! + +echo "[start] Admin panel on :8001" +uvicorn admin_app:app --host 0.0.0.0 --port 8001 --proxy-headers & +ADMIN_PID=$! + +# Wait for either to exit +wait -n "$MAIN_PID" "$ADMIN_PID" +EXIT_CODE=$? +echo "[exit] One of the services exited with code $EXIT_CODE; stopping the other" +kill -TERM "$MAIN_PID" "$ADMIN_PID" 2>/dev/null || true +wait +exit $EXIT_CODE diff --git a/OLD_VERSIONS/v2.0/pst-indexer/frontend/app.js b/OLD_VERSIONS/v2.0/pst-indexer/frontend/app.js new file mode 100644 index 0000000..fdc6cd8 --- /dev/null +++ b/OLD_VERSIONS/v2.0/pst-indexer/frontend/app.js @@ -0,0 +1,869 @@ +/* ============================================================ + PST Archive — frontend logic + - Setup wizard (first run) + - Login (+ MFA) + - Main app: upload, index, search, view + ============================================================ */ + +const API = "/api"; + +const state = { + activePstId: null, + activePstName: "", + folder: "", + query: "", + limit: 25, + offset: 0, + total: 0, + refreshTimer: null, + user: null, + folders: [], // [{id, name, file_count}, ...] + psts: [], // last loaded pst list + collapsedFolders: new Set(), // folder IDs (or "__uncat__") that are collapsed + movePstId: null, // currently being moved +}; + +const $ = (id) => document.getElementById(id); + +// ---------- helpers ---------- +function fmtBytes(n) { + if (!n) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let i = 0; + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } + return `${n.toFixed(i ? 1 : 0)} ${units[i]}`; +} + +function fmtDate(iso) { + if (!iso) return "—"; + try { + const d = new Date(iso); + if (isNaN(d)) return iso; + return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); + } catch { return iso; } +} + +function fmtDateTime(iso) { + if (!iso) return "—"; + try { + const d = new Date(iso); + if (isNaN(d)) return iso; + return d.toLocaleString("en-US", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); + } catch { return iso; } +} + +function toast(msg, ms = 3000) { + const el = $("toast"); + el.textContent = msg; + el.classList.remove("hidden"); + clearTimeout(toast._t); + toast._t = setTimeout(() => el.classList.add("hidden"), ms); +} + +function escapeHtml(s) { + if (s == null) return ""; + return String(s) + .replace(/&/g, "&").replace(//g, ">") + .replace(/"/g, """).replace(/'/g, "'"); +} + +function renderSnippet(snippet) { + if (!snippet) return ""; + return escapeHtml(snippet) + .replace(/<mark>/g, "") + .replace(/<\/mark>/g, ""); +} + +function debounce(fn, ms) { + let t; + return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; +} + +// ---------- API ---------- +async function api(path, opts = {}) { + const res = await fetch(API + path, { + credentials: "same-origin", + ...opts, + }); + let body = null; + try { body = await res.json(); } catch {} + if (!res.ok) { + const err = new Error((body && body.detail) || res.statusText); + err.status = res.status; + err.body = body || {}; + throw err; + } + return body; +} + +// ---------- Screen switching ---------- +function showScreen(screen) { + ["setupScreen", "loginScreen", "mainApp"].forEach((id) => { + $(id).classList.toggle("hidden", id !== screen); + }); +} + +// ---------- Bootstrap ---------- +async function bootstrap() { + // 1. Try to fetch current user + try { + const { user } = await api("/me"); + state.user = user; + enterApp(); + return; + } catch (e) { + if (e.status !== 401) { + showLogin(); + toast("Connection problem — please retry."); + return; + } + } + + // 2. Not logged in — check setup status + try { + const { needs_setup } = await api("/setup/status"); + if (needs_setup) showSetup(); + else showLogin(); + } catch { + showLogin(); + } +} + +// ---------- Setup ---------- +function showSetup() { + showScreen("setupScreen"); + $("setupForm").reset(); + $("setupError").classList.add("hidden"); + setTimeout(() => $("setupForm").username?.focus(), 50); +} + +async function handleSetup(e) { + e.preventDefault(); + const form = e.target; + const errEl = $("setupError"); + errEl.classList.add("hidden"); + + const username = form.username.value.trim(); + const password = form.password.value; + const password2 = form.password2.value; + + if (password !== password2) { + errEl.textContent = "Passwords do not match."; + errEl.classList.remove("hidden"); + return; + } + + try { + const { user } = await api("/setup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + state.user = user; + toast("Welcome — admin account created."); + enterApp(); + } catch (err) { + errEl.textContent = err.message || "Setup failed."; + errEl.classList.remove("hidden"); + } +} + +// ---------- Login ---------- +function showLogin() { + showScreen("loginScreen"); + $("loginForm").reset(); + $("mfaField").classList.add("hidden"); + $("loginError").classList.add("hidden"); + setTimeout(() => $("loginForm").username?.focus(), 50); +} + +async function handleLogin(e) { + e.preventDefault(); + const form = e.target; + const errEl = $("loginError"); + errEl.classList.add("hidden"); + + const payload = { + username: form.username.value.trim(), + password: form.password.value, + }; + const mfaInput = form.totp_code.value.trim(); + if (mfaInput) payload.totp_code = mfaInput; + + try { + const { user } = await api("/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + state.user = user; + toast(`Welcome, ${user.username}.`); + enterApp(); + } catch (err) { + if (err.status === 401 && err.body && err.body.mfa_required) { + $("mfaField").classList.remove("hidden"); + const f = $("loginForm").totp_code; + f.value = ""; + setTimeout(() => f.focus(), 50); + errEl.textContent = "Enter your authenticator code to continue."; + errEl.classList.remove("hidden"); + return; + } + errEl.textContent = err.message || "Login failed."; + errEl.classList.remove("hidden"); + } +} + +async function handleLogout() { + try { await api("/logout", { method: "POST" }); } catch {} + state.user = null; + state.activePstId = null; + resetReader(); + showLogin(); +} + +// ---------- Enter the app ---------- +function enterApp() { + showScreen("mainApp"); + $("userChip").textContent = `${state.user.username}${state.user.role === "admin" ? " · admin" : ""}`; + checkHealth(); + loadPstList(); + setInterval(checkHealth, 30000); +} + +// ---------- Health ---------- +async function checkHealth() { + const dot = $("statusDot"); + const text = $("statusText"); + try { + await api("/health"); + dot.className = "status-dot ok"; + text.textContent = "connected"; + } catch { + dot.className = "status-dot err"; + text.textContent = "offline"; + } +} + +// ---------- PST list ---------- +async function loadPstList() { + try { + const [folders, items] = await Promise.all([ + api("/folders"), + api("/pst-files"), + ]); + state.folders = folders; + state.psts = items; + renderPstList(); + if (items.some((i) => i.status === "indexing")) { + clearTimeout(state.refreshTimer); + state.refreshTimer = setTimeout(loadPstList, 2500); + } + } catch (e) { + if (e.status === 401) { handleLogout(); return; } + toast(`Failed to load archives: ${e.message}`); + } +} + +function pstItemHtml(i) { + const active = i.id === state.activePstId ? "active" : ""; + const disabled = i.status !== "ready"; + const statusText = i.status === "indexing" ? "indexing…" : + i.status === "ready" ? `${i.message_count.toLocaleString()} msgs` : + i.status === "failed" ? "failed" : i.status; + return ` +
  • +
    + ${escapeHtml(i.original_name)} + +
    +
    + ${fmtBytes(i.size_bytes)} · ${fmtDate(i.uploaded_at)} + ${statusText} +
    +
  • + `; +} + +function renderPstList() { + const ul = $("pstList"); + const { folders, psts } = state; + + if (!folders.length && !psts.length) { + ul.innerHTML = '
  • No archives yet.
  • '; + return; + } + + // Group PSTs by folder_id + const byFolder = new Map(); + byFolder.set(null, []); // uncategorized bucket + folders.forEach((f) => byFolder.set(f.id, [])); + psts.forEach((p) => { + const bucket = byFolder.has(p.folder_id) ? p.folder_id : null; + byFolder.get(bucket).push(p); + }); + + const parts = []; + + // Render user-defined folders first (sorted alphabetically — server does this) + for (const f of folders) { + const items = byFolder.get(f.id) || []; + const collapsed = state.collapsedFolders.has(f.id) ? "collapsed" : ""; + parts.push(` +
  • +
    +
    + + ${escapeHtml(f.name)} + ${items.length} +
    + +
    + +
  • + `); + } + + // Uncategorized group — only shown if it has items OR there are no folders + const uncat = byFolder.get(null) || []; + if (uncat.length || !folders.length) { + const collapsed = state.collapsedFolders.has("__uncat__") ? "collapsed" : ""; + parts.push(` +
  • +
    +
    + + Uncategorized + ${uncat.length} +
    +
    + +
  • + `); + } + + ul.innerHTML = parts.join(""); + + // PST click + ul.querySelectorAll(".pst-item").forEach((el) => { + el.addEventListener("click", (e) => { + if (e.target.closest("[data-pst-menu]")) return; + if (el.dataset.disabled) { + toast(el.dataset.status === "indexing" ? "Still indexing — one moment…" : "This archive is unavailable."); + return; + } + selectPst(el.dataset.id, el.dataset.name); + }); + }); + + // PST action menu + ul.querySelectorAll("[data-pst-menu]").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + openPstMenu(e, btn.dataset.pstMenu); + }); + }); + + // Folder header toggle (collapse/expand) + ul.querySelectorAll("[data-folder-header]").forEach((el) => { + el.addEventListener("click", (e) => { + if (e.target.closest("[data-folder-menu]")) return; + const id = el.dataset.folderHeader; + if (state.collapsedFolders.has(id)) state.collapsedFolders.delete(id); + else state.collapsedFolders.add(id); + const group = el.closest(".folder-group"); + group.classList.toggle("collapsed"); + }); + }); + + // Folder action menu + ul.querySelectorAll("[data-folder-menu]").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + openFolderMenu(e, btn.dataset.folderMenu); + }); + }); +} + +// ---------- Context menus ---------- +function closeContextMenu() { + document.querySelectorAll(".context-menu").forEach((el) => el.remove()); +} + +function openContextMenu(x, y, items) { + closeContextMenu(); + const menu = document.createElement("div"); + menu.className = "context-menu"; + items.forEach((it) => { + if (it.divider) { + menu.appendChild(document.createElement("hr")); + return; + } + const btn = document.createElement("button"); + btn.textContent = it.label; + if (it.danger) btn.classList.add("danger"); + btn.addEventListener("click", () => { + closeContextMenu(); + it.onClick(); + }); + menu.appendChild(btn); + }); + document.body.appendChild(menu); + + // Position, keeping menu inside viewport + const rect = menu.getBoundingClientRect(); + const maxX = window.innerWidth - rect.width - 4; + const maxY = window.innerHeight - rect.height - 4; + menu.style.left = Math.min(x, maxX) + "px"; + menu.style.top = Math.min(y, maxY) + "px"; + + // Close on outside click or escape + setTimeout(() => { + document.addEventListener("click", closeContextMenu, { once: true }); + }, 0); +} + +function openPstMenu(event, pstId) { + const p = state.psts.find((x) => x.id === pstId); + if (!p) return; + const r = event.currentTarget.getBoundingClientRect(); + openContextMenu(r.left, r.bottom + 4, [ + { + label: "Move to folder…", + onClick: () => openMoveModal(p), + }, + { divider: true }, + { + label: "Delete archive", + danger: true, + onClick: () => deletePst(p), + }, + ]); +} + +function openFolderMenu(event, folderId) { + const f = state.folders.find((x) => x.id === folderId); + if (!f) return; + const r = event.currentTarget.getBoundingClientRect(); + openContextMenu(r.left, r.bottom + 4, [ + { + label: "Rename folder…", + onClick: () => renameFolder(f), + }, + { divider: true }, + { + label: "Delete folder", + danger: true, + onClick: () => deleteFolder(f), + }, + ]); +} + +// ---------- Folder operations ---------- +function openFolderModal() { + $("folderForm").reset(); + $("folderError").classList.add("hidden"); + $("folderModal").classList.remove("hidden"); + setTimeout(() => $("folderForm").name.focus(), 50); +} + +async function handleCreateFolder(e) { + e.preventDefault(); + const form = e.target; + const errEl = $("folderError"); + errEl.classList.add("hidden"); + try { + await api("/folders", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: form.name.value }), + }); + toast("Folder created."); + closeModals(); + loadPstList(); + } catch (err) { + errEl.textContent = err.message || "Create failed."; + errEl.classList.remove("hidden"); + } +} + +async function renameFolder(f) { + const newName = prompt(`Rename "${f.name}" to:`, f.name); + if (!newName || newName.trim() === f.name) return; + try { + await api(`/folders/${f.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: newName }), + }); + toast("Folder renamed."); + loadPstList(); + } catch (err) { + toast(`Rename failed: ${err.message}`); + } +} + +async function deleteFolder(f) { + if (!confirm(`Delete folder "${f.name}"?\n\nAny archives inside will be moved to Uncategorized.`)) return; + try { + await api(`/folders/${f.id}`, { method: "DELETE" }); + toast("Folder deleted."); + loadPstList(); + } catch (err) { + toast(`Delete failed: ${err.message}`); + } +} + +async function deletePst(p) { + if (!confirm(`Remove "${p.original_name}" and all indexed messages?`)) return; + try { + await api(`/pst-files/${p.id}`, { method: "DELETE" }); + if (state.activePstId === p.id) { state.activePstId = null; resetReader(); } + toast("Archive removed."); + loadPstList(); + } catch (err) { + toast(`Delete failed: ${err.message}`); + } +} + +function openMoveModal(p) { + state.movePstId = p.id; + $("moveSubject").textContent = p.original_name; + + const sel = $("moveFolderSelect"); + sel.innerHTML = '' + + state.folders.map((f) => + `` + ).join(""); + if (!p.folder_id) sel.value = ""; + + $("moveError").classList.add("hidden"); + $("moveModal").classList.remove("hidden"); +} + +async function handleMovePst(e) { + e.preventDefault(); + const errEl = $("moveError"); + errEl.classList.add("hidden"); + const folderId = $("moveFolderSelect").value || null; + try { + await api(`/pst-files/${state.movePstId}/folder`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ folder_id: folderId }), + }); + toast("Moved."); + closeModals(); + loadPstList(); + } catch (err) { + errEl.textContent = err.message || "Move failed."; + errEl.classList.remove("hidden"); + } +} + +function closeModals() { + document.querySelectorAll(".modal").forEach((m) => m.classList.add("hidden")); +} + +// ---------- Selection + folders ---------- +async function selectPst(id, name) { + state.activePstId = id; + state.activePstName = name; + state.folder = ""; + state.query = ""; + state.offset = 0; + + $("searchInput").value = ""; + $("searchInput").disabled = false; + $("folderFilter").disabled = false; + + document.querySelectorAll(".pst-item").forEach((el) => { + el.classList.toggle("active", el.dataset.id === id); + }); + $("readerTitle").textContent = name; + $("readerMeta").textContent = "Loading messages…"; + hideMessageView(); + await Promise.all([loadFolders(), loadMessages()]); +} + +async function loadFolders() { + if (!state.activePstId) return; + try { + const folders = await api(`/pst-files/${state.activePstId}/folders`); + const sel = $("folderFilter"); + sel.innerHTML = + '' + + folders.map((f) => ``).join(""); + sel.value = state.folder || ""; + } catch (e) { console.warn("folders", e); } +} + +// ---------- Messages ---------- +async function loadMessages() { + if (!state.activePstId) return; + const params = new URLSearchParams(); + if (state.query) params.set("q", state.query); + if (state.folder) params.set("folder", state.folder); + params.set("limit", state.limit); + params.set("offset", state.offset); + + try { + const res = await api(`/pst-files/${state.activePstId}/messages?${params}`); + state.total = res.total; + renderMessages(res.items); + renderPaginator(); + $("readerMeta").textContent = + `${state.total.toLocaleString()} message${state.total === 1 ? "" : "s"}` + + (state.query ? ` matching "${state.query}"` : "") + + (state.folder ? ` in ${state.folder}` : ""); + } catch (e) { + if (e.status === 401) { handleLogout(); return; } + toast(`Search failed: ${e.message}`); + } +} + +function renderMessages(items) { + const list = $("messageList"); + if (!items.length) { + list.innerHTML = ` +
    +
    +

    No messages match.

    +
    `; + return; + } + list.innerHTML = items.map((m) => { + const hasAtt = m.attachments && m.attachments.trim().length > 0; + return ` +
    +
    ${escapeHtml(m.sender || "(unknown sender)")}
    +
    +
    ${escapeHtml(m.subject || "(no subject)")} ${hasAtt ? '' : ""}
    +
    ${renderSnippet(m.snippet || "")}
    +
    +
    +
    ${fmtDate(m.sent_date)}
    +
    ${escapeHtml((m.folder || "").split("/").filter(Boolean).pop() || "inbox")}
    +
    +
    `; + }).join(""); + list.querySelectorAll(".message-row").forEach((el) => { + el.addEventListener("click", () => openMessage(el.dataset.id)); + }); +} + +function renderPaginator() { + const p = $("paginator"); + if (state.total <= state.limit) { p.classList.add("hidden"); return; } + p.classList.remove("hidden"); + const from = state.offset + 1; + const to = Math.min(state.offset + state.limit, state.total); + $("pageInfo").textContent = `${from}–${to} of ${state.total.toLocaleString()}`; + $("prevBtn").disabled = state.offset === 0; + $("nextBtn").disabled = state.offset + state.limit >= state.total; +} + +// ---------- Message view ---------- +async function openMessage(id) { + try { + const m = await api(`/messages/${id}`); + $("mvSubject").textContent = m.subject || "(no subject)"; + $("mvSender").textContent = m.sender || "—"; + $("mvRecipients").textContent = m.recipients || "—"; + $("mvDate").textContent = fmtDateTime(m.sent_date); + $("mvFolder").textContent = m.folder || "—"; + if (m.attachments && m.attachments.trim()) { + $("mvAttLabel").classList.remove("hidden"); + $("mvAttachments").classList.remove("hidden"); + $("mvAttachments").textContent = m.attachments.split(" | ").join(", "); + } else { + $("mvAttLabel").classList.add("hidden"); + $("mvAttachments").classList.add("hidden"); + } + $("mvBody").textContent = m.body || "(empty body)"; + $("messageView").classList.remove("hidden"); + } catch (e) { + if (e.status === 401) { handleLogout(); return; } + toast(`Could not open message: ${e.message}`); + } +} + +function hideMessageView() { $("messageView").classList.add("hidden"); } + +function resetReader() { + $("readerTitle").textContent = "Select an archive"; + $("readerMeta").textContent = "Upload a PST file or select one from the left to begin."; + $("searchInput").disabled = true; + $("folderFilter").disabled = true; + $("messageList").innerHTML = ` +
    +
    +

    The archive awaits.

    +
    `; + $("paginator").classList.add("hidden"); + hideMessageView(); +} + +// ---------- Upload ---------- +function uploadFile(file) { + if (!file) return; + const lower = file.name.toLowerCase(); + if (!lower.endsWith(".pst") && !lower.endsWith(".ost")) { + toast("Only .pst and .ost files are accepted."); + return; + } + const progress = $("uploadProgress"); + const bar = $("progressBar"); + const pct = $("progressPct"); + const nm = $("progressName"); + progress.classList.remove("hidden"); + nm.textContent = file.name; + bar.style.width = "0%"; + pct.textContent = "0%"; + + const form = new FormData(); + form.append("file", file); + + const xhr = new XMLHttpRequest(); + xhr.open("POST", API + "/pst-files"); + xhr.withCredentials = true; + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) { + const p = Math.round((e.loaded / e.total) * 100); + bar.style.width = p + "%"; + pct.textContent = p + "%"; + } + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + pct.textContent = "indexing…"; + bar.style.width = "100%"; + toast("Upload complete. Indexing started in background."); + setTimeout(() => progress.classList.add("hidden"), 2500); + loadPstList(); + } else if (xhr.status === 401) { + handleLogout(); + } else { + let msg = "Upload failed"; + try { msg = JSON.parse(xhr.responseText).detail || msg; } catch {} + toast(msg); + progress.classList.add("hidden"); + } + }; + xhr.onerror = () => { toast("Upload failed (network)."); progress.classList.add("hidden"); }; + xhr.send(form); +} + +// ---------- Theme ---------- +function initTheme() { + // Preference order: localStorage → prefers-color-scheme → light + let theme = "light"; + try { + const saved = localStorage.getItem("pst-theme"); + if (saved === "light" || saved === "dark") { + theme = saved; + } else if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) { + theme = "dark"; + } + } catch { /* ignore storage errors */ } + document.documentElement.dataset.theme = theme; +} + +function toggleTheme() { + const current = document.documentElement.dataset.theme === "dark" ? "dark" : "light"; + const next = current === "dark" ? "light" : "dark"; + document.documentElement.dataset.theme = next; + try { localStorage.setItem("pst-theme", next); } catch {} +} + +// Apply theme immediately so there's no flash of wrong theme +initTheme(); + +// ---------- Wire up ---------- +function init() { + $("setupForm").addEventListener("submit", handleSetup); + $("loginForm").addEventListener("submit", handleLogin); + $("logoutBtn").addEventListener("click", handleLogout); + + // Theme toggles (both the in-masthead and floating variants) + ["themeToggle", "themeToggleFloat"].forEach((id) => { + const el = document.getElementById(id); + if (el) el.addEventListener("click", toggleTheme); + }); + + $("fileInput").addEventListener("change", (e) => { + const f = e.target.files[0]; + if (f) uploadFile(f); + e.target.value = ""; + }); + + const dz = $("dropzone"); + ["dragenter", "dragover"].forEach((ev) => { + dz.addEventListener(ev, (e) => { e.preventDefault(); dz.classList.add("drag-over"); }); + }); + ["dragleave", "drop"].forEach((ev) => { + dz.addEventListener(ev, (e) => { e.preventDefault(); dz.classList.remove("drag-over"); }); + }); + dz.addEventListener("drop", (e) => { + const f = e.dataTransfer.files?.[0]; + if (f) uploadFile(f); + }); + + $("refreshBtn").addEventListener("click", loadPstList); + $("newFolderBtn").addEventListener("click", openFolderModal); + $("folderForm").addEventListener("submit", handleCreateFolder); + $("moveForm").addEventListener("submit", handleMovePst); + + // Modal close buttons + backdrop clicks + Escape + document.querySelectorAll("[data-close]").forEach((el) => { + el.addEventListener("click", closeModals); + }); + document.querySelectorAll(".modal").forEach((m) => { + m.addEventListener("click", (e) => { + if (e.target === m) closeModals(); + }); + }); + + const searchHandler = debounce(() => { + state.query = $("searchInput").value.trim(); + state.offset = 0; + loadMessages(); + }, 250); + $("searchInput").addEventListener("input", searchHandler); + + $("folderFilter").addEventListener("change", () => { + state.folder = $("folderFilter").value; + state.offset = 0; + loadMessages(); + }); + + $("prevBtn").addEventListener("click", () => { + state.offset = Math.max(0, state.offset - state.limit); + loadMessages(); + }); + $("nextBtn").addEventListener("click", () => { + state.offset += state.limit; + loadMessages(); + }); + + $("closeMessageBtn").addEventListener("click", hideMessageView); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + hideMessageView(); + closeModals(); + closeContextMenu(); + } + }); + + bootstrap(); +} + +document.addEventListener("DOMContentLoaded", init); diff --git a/OLD_VERSIONS/v2.0/pst-indexer/frontend/index.html b/OLD_VERSIONS/v2.0/pst-indexer/frontend/index.html new file mode 100644 index 0000000..1e0be42 --- /dev/null +++ b/OLD_VERSIONS/v2.0/pst-indexer/frontend/index.html @@ -0,0 +1,256 @@ + + + + + +PST Archive · Mail Indexer + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/OLD_VERSIONS/v2.0/pst-indexer/frontend/styles.css b/OLD_VERSIONS/v2.0/pst-indexer/frontend/styles.css new file mode 100644 index 0000000..8aa34f3 --- /dev/null +++ b/OLD_VERSIONS/v2.0/pst-indexer/frontend/styles.css @@ -0,0 +1,1136 @@ +/* ============================================================ + PST ARCHIVE — aesthetic: editorial/forensic archive + Warm paper palette, serif display, mono accents, grain overlay + Supports light (default) and dark themes via [data-theme] + ============================================================ */ + +:root, +:root[data-theme="light"] { + --paper: #f2ede3; + --paper-deep: #e8e1d3; + --ink: #1b1814; + --ink-soft: #3a332a; + --ink-muted: #6b6258; + --rule: #c9bfad; + --rule-soft: #dcd3c1; + --accent: #a8341f; /* oxblood red */ + --accent-soft: #c95a42; + --highlight: #e8d67a; /* faded yellow marker */ + --ok: #2e6f3a; + --warn: #a86b1e; + --shadow: 0 1px 0 rgba(27, 24, 20, 0.04), 0 20px 40px -20px rgba(27, 24, 20, 0.25); + --grain-opacity: 0.35; + + --font-display: "Fraunces", "Iowan Old Style", "Palatino", Georgia, serif; + --font-mono: "JetBrains Mono", "SFMono-Regular", ui-monospace, monospace; +} + +:root[data-theme="dark"] { + --paper: #17140f; /* deep inked paper */ + --paper-deep: #201c16; /* slightly lighter ink-pool for surfaces */ + --ink: #e9e0cb; /* warm paper-cream as the ink color */ + --ink-soft: #c4b99f; + --ink-muted: #8a8170; + --rule: #3a3226; + --rule-soft: #2a251d; + --accent: #d06347; /* softened oxblood that holds up on dark */ + --accent-soft: #e4876c; + --highlight: #d8b64a; /* brass-yellow marker */ + --ok: #6fae7c; + --warn: #d99a4a; + --shadow: 0 1px 0 rgba(0, 0, 0, 0.4), 0 20px 40px -20px rgba(0, 0, 0, 0.9); + --grain-opacity: 0.12; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + height: 100%; + background: var(--paper); + color: var(--ink); + font-family: var(--font-display); + font-feature-settings: "ss01", "onum"; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + display: flex; + flex-direction: column; + min-height: 100vh; + background-image: + radial-gradient(circle at 10% 10%, rgba(168, 52, 31, 0.04), transparent 40%), + radial-gradient(circle at 90% 90%, rgba(74, 58, 42, 0.05), transparent 50%); +} + +:root[data-theme="dark"] body { + background-image: + radial-gradient(circle at 10% 10%, rgba(208, 99, 71, 0.06), transparent 40%), + radial-gradient(circle at 90% 90%, rgba(216, 182, 74, 0.03), transparent 50%); +} + +/* grain overlay */ +.grain { + pointer-events: none; + position: fixed; + inset: 0; + z-index: 9999; + opacity: var(--grain-opacity); + mix-blend-mode: multiply; + background-image: url("data:image/svg+xml;utf8,"); +} + +:root[data-theme="dark"] .grain { + mix-blend-mode: screen; +} + +/* ============================================================ + MASTHEAD + ============================================================ */ +.masthead { + display: flex; + justify-content: space-between; + align-items: flex-end; + padding: 28px 40px 18px; + border-bottom: 1px solid var(--ink); + position: relative; +} + +.masthead::after { + content: ""; + position: absolute; + bottom: -4px; + left: 40px; + right: 40px; + height: 1px; + background: var(--ink); +} + +.masthead-left { display: flex; align-items: center; gap: 18px; } + +.logo-mark { + color: var(--ink); + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border: 1px solid var(--ink); + background: var(--paper); +} + +.masthead-title h1 { + margin: 0; + font-size: 2.5rem; + font-weight: 500; + letter-spacing: -0.02em; + line-height: 1; + font-variation-settings: "SOFT" 30, "WONK" 1; +} + +.tagline { + margin: 6px 0 0; + font-family: var(--font-mono); + font-size: 0.7rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--ink-muted); +} + +.masthead-right { + display: flex; + align-items: center; + gap: 10px; + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-muted); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--warn); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 25%, transparent); +} +.status-dot.ok { background: var(--ok); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 25%, transparent); } +.status-dot.err { background: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent); } + +/* ============================================================ + WORKSPACE + ============================================================ */ +.workspace { + display: grid; + grid-template-columns: 340px 1fr; + flex: 1; + min-height: 0; +} + +/* ============================================================ + SIDEBAR + ============================================================ */ +.sidebar { + border-right: 1px solid var(--rule); + padding: 28px 24px; + display: flex; + flex-direction: column; + gap: 36px; + overflow-y: auto; + background: linear-gradient(180deg, rgba(232, 225, 211, 0.4), transparent 40%); +} + +.section-heading { + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ink-soft); + margin: 0 0 14px; + padding-bottom: 8px; + border-bottom: 1px solid var(--rule); + display: flex; + align-items: baseline; + justify-content: space-between; +} + +.dropzone { + display: block; + border: 1.5px dashed var(--ink-muted); + background: var(--paper-deep); + padding: 28px 18px; + text-align: center; + cursor: pointer; + transition: all 0.2s ease; + position: relative; +} +.dropzone:hover, +.dropzone.drag-over { + border-color: var(--accent); + background: var(--rule-soft); + transform: translateY(-1px); +} +.dropzone-inner { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + color: var(--ink-soft); +} +.dropzone-title { + font-family: var(--font-display); + font-size: 1.1rem; + font-style: italic; + font-weight: 400; +} +.dropzone-title em { + font-style: normal; + font-family: var(--font-mono); + font-size: 0.9rem; + color: var(--accent); +} +.dropzone-hint { + font-family: var(--font-mono); + font-size: 0.65rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--ink-muted); +} + +.upload-progress { margin-top: 14px; } +.progress-label { + display: flex; + justify-content: space-between; + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--ink-muted); + margin-bottom: 6px; +} +.progress-track { + height: 4px; + background: var(--rule-soft); + overflow: hidden; +} +.progress-bar { + height: 100%; + width: 0%; + background: var(--accent); + transition: width 0.2s ease; +} + +.archive-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; +} +.archive-head .section-heading { + margin: 0; + border: none; + padding: 0; +} + +.icon-btn { + background: transparent; + border: 1px solid var(--rule); + padding: 5px 8px; + cursor: pointer; + color: var(--ink-soft); + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; +} +.icon-btn:hover { + border-color: var(--ink); + color: var(--accent); +} + +.pst-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; } + +.pst-item { + padding: 14px 14px; + border: 1px solid var(--rule); + background: var(--paper); + cursor: pointer; + transition: all 0.15s ease; + position: relative; +} +.pst-item:hover { + border-color: var(--ink); + transform: translateX(2px); +} +.pst-item.active { + border-color: var(--ink); + background: var(--ink); + color: var(--paper); + box-shadow: 4px 4px 0 var(--accent); +} +.pst-item.active .pst-meta { color: color-mix(in srgb, var(--paper) 65%, transparent); } + +.pst-name { + font-size: 1rem; + font-weight: 500; + letter-spacing: -0.01em; + margin: 0 0 6px; + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; +} +.pst-name-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.pst-menu { + background: none; + border: none; + color: inherit; + opacity: 0.4; + cursor: pointer; + font-size: 1.1rem; + line-height: 1; + padding: 0 4px; + letter-spacing: 0.1em; + transition: opacity 0.15s, color 0.15s; +} +.pst-menu:hover { opacity: 1; color: var(--accent); } +.pst-item.active .pst-menu:hover { color: var(--accent-soft); } + +.pst-meta { + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.08em; + color: var(--ink-muted); + display: flex; + justify-content: space-between; + gap: 8px; +} + +.pst-status { + text-transform: uppercase; + letter-spacing: 0.12em; +} +.pst-status.indexing { color: var(--warn); } +.pst-status.ready { color: var(--ok); } +.pst-status.failed { color: var(--accent); } +.pst-item.active .pst-status.indexing { color: color-mix(in srgb, var(--warn) 70%, var(--paper) 30%); } +.pst-item.active .pst-status.ready { color: color-mix(in srgb, var(--ok) 70%, var(--paper) 30%); } +.pst-item.active .pst-status.failed { color: var(--accent-soft); } + +.empty-state { + color: var(--ink-muted); + font-style: italic; + text-align: center; + padding: 20px 0; + font-size: 0.9rem; +} + +/* ============================================================ + FOLDER GROUPS IN SIDEBAR + ============================================================ */ +.archive-actions { + display: flex; + gap: 4px; +} + +.folder-group { + list-style: none; + padding: 0; + margin: 0; +} + +.folder-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 4px; + margin: 14px 0 6px; + cursor: pointer; + user-select: none; + font-family: var(--font-mono); + font-size: 0.7rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-soft); + border-bottom: 1px solid var(--rule); + transition: color 0.15s; +} +.folder-group:first-child .folder-header { margin-top: 4px; } + +.folder-header:hover { color: var(--accent); } + +.folder-header-left { + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; +} + +.folder-caret { + display: inline-block; + width: 8px; + transition: transform 0.2s; + color: var(--ink-muted); +} +.folder-group.collapsed .folder-caret { transform: rotate(-90deg); } + +.folder-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.folder-count { + color: var(--ink-muted); + font-size: 0.65rem; + margin-left: 4px; +} + +.folder-menu-btn { + background: none; + border: none; + color: var(--ink-muted); + cursor: pointer; + padding: 2px 6px; + font-size: 0.9rem; + line-height: 1; + opacity: 0; + transition: opacity 0.15s, color 0.15s; +} +.folder-header:hover .folder-menu-btn { opacity: 1; } +.folder-menu-btn:hover { color: var(--accent); } + +.folder-items { + display: flex; + flex-direction: column; + gap: 8px; + overflow: hidden; + transition: max-height 0.25s ease-out; + max-height: 2000px; +} +.folder-group.collapsed .folder-items { + max-height: 0; +} + +.folder-empty { + font-style: italic; + font-size: 0.8rem; + color: var(--ink-muted); + padding: 6px 4px; +} + +/* Context menu for folder/PST actions */ +.context-menu { + position: fixed; + background: var(--paper); + border: 1px solid var(--ink); + box-shadow: 4px 4px 0 var(--rule); + z-index: 500; + min-width: 160px; + padding: 4px 0; +} +.context-menu button { + display: block; + width: 100%; + text-align: left; + background: none; + border: none; + padding: 8px 14px; + font-family: var(--font-display); + font-size: 0.9rem; + color: var(--ink); + cursor: pointer; +} +.context-menu button:hover { + background: var(--paper-deep); + color: var(--accent); +} +.context-menu button.danger:hover { + color: var(--accent); +} +.context-menu hr { + border: none; + border-top: 1px solid var(--rule-soft); + margin: 4px 0; +} + +/* ============================================================ + MODAL + ============================================================ */ +.modal { + position: fixed; + inset: 0; + background: color-mix(in srgb, var(--ink) 40%, transparent); + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + z-index: 200; + animation: fadeIn 0.2s ease-out; +} + +.modal-card { + background: var(--paper); + border: 1px solid var(--ink); + padding: 36px 36px 30px; + max-width: 420px; + width: 100%; + box-shadow: 8px 8px 0 var(--accent); + position: relative; +} +.modal-card h2 { + margin: 0 0 4px; + font-size: 1.5rem; + font-weight: 500; + letter-spacing: -0.015em; +} +.modal-subtitle { + margin: 0 0 24px; + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--ink-muted); + letter-spacing: 0.06em; +} +.modal-close { + position: absolute; + top: 10px; + right: 14px; + background: none; + border: none; + font-size: 1.8rem; + line-height: 1; + cursor: pointer; + color: var(--ink-muted); + transition: color 0.15s, transform 0.15s; +} +.modal-close:hover { + color: var(--accent); + transform: rotate(90deg); +} + +.auth-label select { + font-family: var(--font-display); + font-size: 1rem; + padding: 10px 12px; + border: 1px solid var(--ink); + background: var(--paper-deep); + color: var(--ink); + outline: none; + cursor: pointer; +} +.auth-label select:focus { + background: var(--paper); + box-shadow: 3px 3px 0 var(--accent); +} + +/* ============================================================ + READER + ============================================================ */ +.reader { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; +} + +.reader-head { + padding: 28px 40px 20px; + border-bottom: 1px solid var(--rule); + background: + linear-gradient(180deg, rgba(232, 225, 211, 0.3), transparent); +} + +.reader-title h2 { + margin: 0; + font-size: 2rem; + font-weight: 500; + letter-spacing: -0.02em; + line-height: 1.1; +} + +.reader-meta { + margin: 6px 0 20px; + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-muted); +} + +.search-wrap { + display: grid; + grid-template-columns: 1fr 200px; + gap: 10px; +} + +#searchInput, #folderFilter { + font-family: var(--font-display); + font-size: 1rem; + padding: 10px 14px; + border: 1px solid var(--ink); + background: var(--paper); + color: var(--ink); + outline: none; + transition: box-shadow 0.15s; +} +#searchInput:focus, #folderFilter:focus { + box-shadow: 3px 3px 0 var(--accent); +} +#searchInput:disabled, #folderFilter:disabled { + border-color: var(--rule); + color: var(--ink-muted); + background: var(--paper-deep); + cursor: not-allowed; +} +#searchInput::placeholder { + color: var(--ink-muted); + font-style: italic; +} +#folderFilter { cursor: pointer; font-family: var(--font-mono); font-size: 0.8rem; } + +.reader-body { + flex: 1; + overflow-y: auto; + padding: 20px 40px; + position: relative; +} + +.reader-placeholder { + text-align: center; + padding: 80px 20px; + color: var(--ink-muted); +} +.placeholder-ornament { + font-size: 2rem; + color: var(--accent); + margin-bottom: 16px; +} +.reader-placeholder p { + font-style: italic; + font-size: 1.1rem; + margin: 0; +} + +/* ============================================================ + MESSAGE LIST + ============================================================ */ +.message-list { + display: flex; + flex-direction: column; +} + +.message-row { + padding: 16px 0; + border-bottom: 1px solid var(--rule-soft); + cursor: pointer; + transition: all 0.12s ease; + display: grid; + grid-template-columns: 180px 1fr 140px; + gap: 20px; + align-items: baseline; +} +.message-row:hover { + background: color-mix(in srgb, var(--paper-deep) 70%, transparent); + padding-left: 8px; + padding-right: 8px; +} + +.mr-sender { + font-weight: 500; + color: var(--ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.95rem; +} + +.mr-main { + min-width: 0; + overflow: hidden; +} +.mr-subject { + font-size: 1.05rem; + font-weight: 500; + margin: 0 0 4px; + color: var(--ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mr-snippet { + font-size: 0.85rem; + color: var(--ink-muted); + line-height: 1.5; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; +} +.mr-snippet mark { + background: var(--highlight); + color: var(--ink); + padding: 0 2px; + font-weight: 500; +} + +.mr-meta { + text-align: right; + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--ink-muted); + letter-spacing: 0.04em; +} +.mr-folder { + margin-top: 4px; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--accent); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mr-attached { + display: inline-block; + margin-left: 4px; + color: var(--accent); +} + +/* ============================================================ + MESSAGE VIEW + ============================================================ */ +.message-view { + position: absolute; + inset: 20px 40px; + background: var(--paper); + border: 1px solid var(--ink); + box-shadow: 8px 8px 0 var(--rule); + overflow-y: auto; + padding: 40px 50px; + animation: slideIn 0.25s ease-out; +} + +@keyframes slideIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.close-btn { + position: absolute; + top: 14px; + right: 18px; + background: none; + border: none; + font-size: 2rem; + line-height: 1; + cursor: pointer; + color: var(--ink-muted); + transition: color 0.15s, transform 0.15s; +} +.close-btn:hover { + color: var(--accent); + transform: rotate(90deg); +} + +.message-header { + border-bottom: 2px solid var(--ink); + padding-bottom: 20px; + margin-bottom: 24px; +} + +.message-subject { + font-size: 1.75rem; + font-weight: 500; + line-height: 1.2; + letter-spacing: -0.015em; + margin-bottom: 16px; +} + +.message-meta { + display: grid; + grid-template-columns: 80px 1fr; + gap: 4px 20px; + margin: 0; +} +.message-meta dt { + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--ink-muted); + padding-top: 2px; +} +.message-meta dd { + margin: 0; + font-size: 0.95rem; + color: var(--ink-soft); + word-break: break-word; +} + +.message-body { + font-family: var(--font-display); + font-size: 1rem; + line-height: 1.7; + color: var(--ink); + white-space: pre-wrap; + word-wrap: break-word; + max-width: 70ch; +} + +/* ============================================================ + PAGINATOR + ============================================================ */ +.paginator { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 40px; + border-top: 1px solid var(--rule); + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--ink-soft); + background: var(--paper-deep); +} +.paginator button { + background: none; + border: 1px solid var(--ink); + padding: 6px 14px; + cursor: pointer; + font-family: inherit; + font-size: inherit; + color: var(--ink); + transition: all 0.15s; +} +.paginator button:hover:not(:disabled) { + background: var(--ink); + color: var(--paper); +} +.paginator button:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +/* ============================================================ + TOAST + ============================================================ */ +.toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: var(--ink); + color: var(--paper); + padding: 12px 20px; + font-family: var(--font-mono); + font-size: 0.8rem; + letter-spacing: 0.06em; + border-left: 3px solid var(--accent); + box-shadow: var(--shadow); + z-index: 1000; + animation: toastSlide 0.3s ease-out; +} +@keyframes toastSlide { + from { opacity: 0; transform: translate(-50%, 20px); } + to { opacity: 1; transform: translate(-50%, 0); } +} + +.hidden { display: none !important; } + +/* ============================================================ + AUTH SCREENS (login + setup) + ============================================================ */ +.auth-screen { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: + radial-gradient(circle at 30% 20%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 45%), + radial-gradient(circle at 70% 80%, color-mix(in srgb, var(--ink) 10%, transparent), transparent 55%), + var(--paper); + z-index: 100; + animation: fadeIn 0.3s ease-out; +} +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.auth-card { + width: 100%; + max-width: 440px; + background: var(--paper); + border: 1px solid var(--ink); + padding: 44px 40px 36px; + box-shadow: 8px 8px 0 var(--rule); + position: relative; +} +.auth-card::before { + content: ""; + position: absolute; + top: -6px; + left: -6px; + right: 6px; + bottom: 6px; + border: 1px solid var(--ink); + pointer-events: none; + z-index: -1; + background: var(--paper-deep); +} + +.auth-brand { + text-align: center; + margin-bottom: 28px; + padding-bottom: 20px; + border-bottom: 1px solid var(--rule); +} +.auth-brand svg { color: var(--ink); margin: 0 auto 12px; display: block; } +.auth-brand h1 { + margin: 0; + font-size: 2rem; + font-weight: 500; + letter-spacing: -0.02em; + line-height: 1; +} +.auth-tagline { + margin: 8px 0 0; + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--ink-muted); +} + +.auth-form { display: flex; flex-direction: column; gap: 16px; } + +.auth-intro { + margin: 0 0 6px; + font-size: 0.92rem; + color: var(--ink-soft); + line-height: 1.5; + font-style: italic; +} + +.auth-label { + display: flex; + flex-direction: column; + gap: 6px; +} +.auth-label > span { + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--ink-soft); +} +.auth-label small { + font-family: var(--font-mono); + font-size: 0.66rem; + color: var(--ink-muted); + letter-spacing: 0.04em; +} +.auth-label input { + font-family: var(--font-display); + font-size: 1rem; + padding: 10px 12px; + border: 1px solid var(--ink); + background: var(--paper-deep); + color: var(--ink); + outline: none; + transition: box-shadow 0.15s, background 0.15s; +} +.auth-label input:focus { + background: var(--paper); + box-shadow: 3px 3px 0 var(--accent); +} + +.auth-btn { + margin-top: 8px; + padding: 12px 18px; + font-family: var(--font-display); + font-size: 1.05rem; + font-weight: 500; + letter-spacing: -0.01em; + background: var(--ink); + color: var(--paper); + border: 1px solid var(--ink); + cursor: pointer; + transition: all 0.15s; +} +.auth-btn:hover { + background: var(--accent); + border-color: var(--accent); + transform: translate(-1px, -1px); + box-shadow: 3px 3px 0 var(--ink); +} + +.auth-error { + padding: 10px 14px; + background: color-mix(in srgb, var(--accent) 10%, transparent); + border-left: 3px solid var(--accent); + color: var(--accent); + font-size: 0.88rem; + font-family: var(--font-mono); + letter-spacing: 0.02em; +} + +/* ============================================================ + USER CHIP in masthead + ============================================================ */ +.user-chip { + font-family: var(--font-mono); + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-soft); + padding: 4px 10px; + border: 1px solid var(--rule); + background: var(--paper-deep); + margin-left: 12px; +} + +.logout-btn { + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-soft); + background: transparent; + border: 1px solid var(--rule); + padding: 5px 10px; + cursor: pointer; + transition: all 0.15s; + margin-left: 8px; +} +.logout-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +/* ============================================================ + THEME TOGGLE + ============================================================ */ +.theme-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--rule); + background: var(--paper-deep); + color: var(--ink-soft); + cursor: pointer; + padding: 0; + transition: all 0.2s ease; + position: relative; + overflow: hidden; +} +.theme-toggle:hover { + border-color: var(--accent); + color: var(--accent); + transform: translateY(-1px); +} +.theme-toggle svg { + width: 16px; + height: 16px; + transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); +} +.theme-toggle:hover svg { + transform: rotate(25deg); +} +:root[data-theme="dark"] .theme-toggle:hover svg { + transform: rotate(-25deg); +} + +/* Icon visibility — show sun in light mode, moon in dark mode */ +.theme-toggle .icon-sun { display: inline-block; } +.theme-toggle .icon-moon { display: none; } +:root[data-theme="dark"] .theme-toggle .icon-sun { display: none; } +:root[data-theme="dark"] .theme-toggle .icon-moon { display: inline-block; } + +/* Floating variant shown on auth/setup screens */ +.theme-toggle-float { + position: fixed; + top: 20px; + right: 20px; + z-index: 200; +} +/* Hide the floating toggle once the main app is visible (in-masthead toggle takes over) */ +body:has(#mainApp:not(.hidden)) .theme-toggle-float { display: none; } + +/* ============================================================ + SCROLLBARS + ============================================================ */ +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: var(--rule); + border: 2px solid var(--paper); +} +::-webkit-scrollbar-thumb:hover { background: var(--ink-muted); } + +/* ============================================================ + RESPONSIVE + ============================================================ */ +@media (max-width: 900px) { + .workspace { grid-template-columns: 1fr; } + .sidebar { border-right: none; border-bottom: 1px solid var(--rule); max-height: 50vh; } + .masthead { padding: 20px 24px 14px; flex-wrap: wrap; gap: 16px; } + .reader-head, .reader-body, .paginator { padding-left: 24px; padding-right: 24px; } + .search-wrap { grid-template-columns: 1fr; } + .message-row { grid-template-columns: 1fr; gap: 6px; } + .mr-meta { text-align: left; } + .message-view { inset: 12px; padding: 24px 20px; } + .message-meta { grid-template-columns: 60px 1fr; } +} diff --git a/OLD_VERSIONS/v2.1 b/OLD_VERSIONS/v2.1 new file mode 160000 index 0000000..a95f71d --- /dev/null +++ b/OLD_VERSIONS/v2.1 @@ -0,0 +1 @@ +Subproject commit a95f71dc2a9a621e6bf24a525b27d6c6833d6b3a diff --git a/OLD_VERSIONS/v2.2/pst-indexer.zip b/OLD_VERSIONS/v2.2/pst-indexer.zip new file mode 100644 index 0000000..674bbdd Binary files /dev/null and b/OLD_VERSIONS/v2.2/pst-indexer.zip differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..842fd15 --- /dev/null +++ b/README.md @@ -0,0 +1,1172 @@ +# PST Archive — Mail Indexer + +A self-hosted, Dockerized application for **uploading Microsoft Outlook PST/OST files**, **indexing every email inside them**, and **searching the contents through a web browser** — with full **user authentication**, **multi-factor authentication (MFA)**, **encrypted-at-rest credentials**, and a **built-in administration section** for admin users. + +Designed for **forensic review, e-discovery, personal archive browsing, and migration audits** — any situation where you need to crack open a PST and actually find things inside it without loading it into Outlook. + +--- + +## What's new in v2.2 + +- **Administration now runs inside the main application** — there is no longer a + separate admin process or admin port (previously the panel ran on its own + port, e.g. mapped to `9001`). Everything is served on the single app port + (`8000` by default). +- **"Administration" button** — admin users see an **ADMINISTRATION** button in + the top-right of the masthead, next to their username. It opens the + administration section at `/admin`. The button is not shown to non-admin + users. +- **Access is restricted to admins** — the administration UI and every + `/api/admin/*` endpoint require an authenticated session **and** the `admin` + role. Non-admins receive `403`; unauthenticated requests receive `401`. +- **Footer** — every in-app page (the archive view and the administration view) + now shows `© 2026 Martinhal IT - Joao Vaz - Version 2.2`. The login/setup + screen has no footer. +- **Updating is drop-in** — extract this release over your existing install and + rebuild; see [Updating to a new version](#updating-to-a-new-version). Your + `./data` directory (database, uploaded PSTs, encryption key) is never touched. + +> Historical note: some deep-dive sections below still describe the original +> two-port design (main app + separate admin panel) for reference. Operationally, +> as of v2.2 both are the same process on one port, with administration at +> `/admin`. + +--- + +## Table of Contents + +1. [Features](#features) +2. [Quick Start](#quick-start) +3. [First-Run Setup](#first-run-setup) +4. [Running as a Daemon](#running-as-a-daemon) +5. [Theming & Dark Mode](#theming--dark-mode) +6. [Authentication & Security Model](#authentication--security-model) +7. [Admin Panel](#admin-panel) +8. [Organizing PSTs with Folders](#organizing-psts-with-folders) +9. [Folder Access Permissions](#folder-access-permissions) +10. [Multi-Factor Authentication (MFA)](#multi-factor-authentication-mfa) +11. [Encryption at Rest](#encryption-at-rest) +12. [How It Works](#how-it-works) +13. [Architecture](#architecture) +14. [Project Layout](#project-layout) +15. [Data Model](#data-model) +16. [REST API Reference](#rest-api-reference) +17. [Frontend Interfaces](#frontend-interfaces) +18. [Search Syntax](#search-syntax) +19. [Configuration](#configuration) +20. [Persistence & Backup](#persistence--backup) +21. [Troubleshooting](#troubleshooting) +22. [Performance Notes](#performance-notes) +23. [Security Considerations](#security-considerations) +24. [Extending the App](#extending-the-app) + +--- + +## Features + +### Core +- **Drag-and-drop upload** of `.pst` and `.ost` files through the browser +- **Streaming upload with progress bar** — handles multi-gigabyte archives +- **Background indexing** into SQLite with **FTS5 full-text search** (ranked, prefix-matched, with highlighted snippets) +- **Multi-archive support**: switch between uploaded PSTs; each is searched independently +- **Folder organization**: create folders in the sidebar to group related PST files (e.g. by client, case, year). Move archives between folders, rename folders, or delete folders (any archives inside are safely moved to Uncategorized) +- **Per-folder access permissions**: admins can restrict which non-admin users can view each folder. A single user can be granted access to many folders; a single folder can be shared with many users (true many-to-many) +- **Folder filter**: narrow search to any folder within the archive (Inbox, Sent Items, nested folders) +- **Full message viewer**: subject, sender, recipients, date, folder, attachments, body +- **Persistent storage**: everything lives in a mounted `./data` volume + +### Security +- **First-run setup wizard** forces creation of an initial administrator before any other action is possible +- **Password authentication** using **Argon2id** (winner of the Password Hashing Competition) with constant-time verification +- **TOTP-based MFA** (RFC 6238; compatible with Google Authenticator, Authy, 1Password, Microsoft Authenticator, and every other standard authenticator app) +- **Encryption at rest** for all user PII — usernames, password hashes, and TOTP secrets are stored as Fernet (AES-128-CBC + HMAC-SHA256) ciphertexts +- **Master key** auto-generated on first run and stored with `chmod 600`, or overridable via `MASTER_SECRET` environment variable +- **HMAC-based username lookup** — usernames are never stored in plaintext but are still indexable for login +- **JWT session cookies**, signed with an HKDF-derived sub-key, HttpOnly, SameSite=Lax +- **Rate limiting** on login attempts (10 failures per 5 minutes per IP+username) +- **Admin-only user management**: create, delete, role-change, password-reset, MFA-enroll other users +- **Admin file & folder management**: system-wide view of every uploaded PST and every folder, with create/rename/delete for folders and force-delete for any file regardless of uploader +- **Built-in administration section** for admins (at `/admin`) — restricted to the `admin` role, and lockable at the path level behind a reverse proxy/VPN + +### UI +- **Light and dark themes** on both the main app and the admin panel, with a sun/moon toggle in the masthead and on login/setup screens +- Preference persists in `localStorage` per-browser and respects the operating system's `prefers-color-scheme` on first visit +- Theme applied inline in `` before stylesheets load — **no flash of wrong theme** on page load +- Dark mode uses a custom "inked paper" palette (deep charcoal with warm paper-cream text and softened oxblood accent) — intentional character, not generic-dark +- Editorial typography (Fraunces serif + JetBrains Mono), grain overlay, responsive layout down to mobile widths + +--- + +## Quick Start + +### Prerequisites + +- Docker 20.10 or newer +- Docker Compose v2 +- A `.pst` or `.ost` file to index + +### Run (as a daemon — recommended) + +```bash +docker compose up -d --build +``` + +The `-d` flag runs the container in the background (detached). On the first run this takes **3–5 minutes** because `libpff-python` compiles a C extension from source; subsequent startups are nearly instant. + +The container is configured with `restart: unless-stopped`, so it will automatically come back up after a reboot or a crash, and keep running across Docker daemon restarts — until you explicitly stop it. + +Once the container is up: + +- **App**: `http://localhost:8000` +- **Administration**: `http://localhost:8000/admin` — or just click the + **ADMINISTRATION** button in the top-right after signing in as an admin. + (Non-admin users do not see the button and cannot reach `/admin`.) + +### Foreground mode (useful for debugging) + +```bash +docker compose up --build +``` + +This runs the container attached to your terminal so you can see logs inline. Press `Ctrl-C` to stop. + +### Tail the logs + +```bash +docker compose logs -f # follow both streams +docker compose logs -f pst-indexer # explicit service name +``` + +### Check status + +```bash +docker compose ps # is it running? +docker compose top # which processes? +``` + +### Stop + +```bash +docker compose stop # stop, keep data, keep container +docker compose down # stop and remove the container, keep data volume +``` + +### Full reset + +```bash +docker compose down +rm -rf ./data # erases everything including users and the master key +docker compose up -d --build +``` + +### Restart / update + +```bash +# After editing code or pulling new images: +docker compose up -d --build # rebuild & restart with one command +# Or just restart without rebuilding: +docker compose restart +``` + +--- + +## First-Run Setup + +On the very first visit to `http://localhost:8000`, you will be presented with a **setup wizard** that asks you to create the initial administrator: + +1. Choose a username (3–64 characters) +2. Choose a password (8+ characters — use a passphrase you'll remember) +3. Confirm the password + +The setup endpoint is only callable **while the users table is empty**. Once the first admin is created, the endpoint returns `409 Conflict` and is effectively disabled. + +After setup, you are automatically signed in and landed on the main app. + +**Heads up:** the very first account created through the setup wizard is an **administrator**. After that, sign in normally and use the **ADMINISTRATION** button (top-right) to manage users, folders, files, and permissions. The administration section and its API are only accessible to admin users. + +--- + +## Running as a Daemon + +By default, this app is designed to **run as a long-lived background service**. The `docker-compose.yml` sets `restart: unless-stopped`, which means once started, the container will: + +- Keep running after you close your terminal +- Automatically restart if the process crashes +- Automatically restart when the host reboots and Docker comes back up +- **Not** restart if you explicitly stop it with `docker compose stop` (hence "unless-stopped") + +### Starting the daemon + +```bash +docker compose up -d --build +``` + +The `-d` flag is short for "detached" — the container is launched in the background and the command returns immediately. Your terminal is free to do other things; the container keeps running. + +### Verifying it's running + +```bash +docker compose ps +``` + +You should see output like: + +``` +NAME IMAGE STATUS PORTS +pst-indexer pst-indexer-pst-indexer Up 2 minutes (healthy) 0.0.0.0:8000->8000/tcp +``` + +The `(healthy)` flag comes from the built-in healthcheck, which probes both `/api/health` endpoints every 30 seconds. + +### Watching logs + +Since the container is detached, you won't see logs in your terminal by default. Attach to them on demand: + +```bash +docker compose logs # print recent logs and exit +docker compose logs -f # follow (live tail), Ctrl-C to detach +docker compose logs -f --tail=100 # follow, starting from the last 100 lines +docker compose logs --since 1h # only logs from the last hour +``` + +Detaching from `-f` does **not** stop the container — it just stops showing you the logs. + +### Stopping + +```bash +docker compose stop # stop the container but keep it around +docker compose start # start it again if previously stopped +docker compose restart # stop and start in one command +docker compose down # stop and remove the container (data volume is kept) +docker compose down -v # stop, remove container, AND remove anonymous volumes +``` + +None of these delete your `./data` directory — that's a bind mount, not a Docker-managed volume, so it's always yours. + +### Survives reboots + +Because `restart: unless-stopped` is set, after a host reboot Docker will automatically start the container as soon as the Docker daemon comes up. Nothing manual required on your part. + +If you ever explicitly stopped it with `docker compose stop`, Docker will *not* auto-start it after reboot (that's the whole point of "unless-stopped"). Run `docker compose start` or `docker compose up -d` to bring it back. + +### Updating to a new version + +This release is a **drop-in update**: extract the archive over your existing +installation directory (the one containing `docker-compose.yml` and your +`data/` folder) and rebuild. The archive contains only application code — it +has **no `data/` directory**, so your database, uploaded PSTs, and encryption +key are never overwritten. + +```bash +# From the parent directory that holds your existing install folder: +# (the folder with docker-compose.yml and data/ inside it) +7z x pst-archive-v2.2.7z # extract, overwriting code files in place + +# Then rebuild and recreate the container: +cd +docker compose up -d --build +``` + +Docker Compose compares the current image to the built one; if they differ, it rebuilds and recreates the container with zero-downtime-style replacement. Your `./data` volume is preserved throughout. + +**Notes for this update (v2.2):** + +- The separate admin port is gone. If your `docker-compose.yml` previously + published a second port for the admin panel (for example `"9001:8001"` or + `"8001:8001"`), that line has been removed in the shipped compose file. If you + maintain a customized compose file, simply delete the admin-port line — the + administration section is now served on the main port at `/admin`. +- No database migration is required. The schema is unchanged, so all existing + users, folders, files, permissions, and MFA enrollments carry over as-is. +- A rebuild (`--build`) is required so the updated backend and frontend are + baked into the image. + +--- + +## Theming & Dark Mode + +Both the main app and the admin panel support **light and dark themes**, with a sun/moon toggle visible in the masthead (when signed in) and floating in the top-right corner on the login/setup screens. + +### How the toggle works + +- Click the icon to flip between themes +- Choice is saved to `localStorage["pst-theme"]` in your browser +- On a fresh browser (nothing saved), the app respects your operating system's `prefers-color-scheme` setting +- The theme is applied by an inline ` + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/admin_app.py b/backend/admin_app.py new file mode 100644 index 0000000..917217b --- /dev/null +++ b/backend/admin_app.py @@ -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") diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..10cbd89 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,1234 @@ +""" +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, '', '', '…', 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") diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..9937c82 --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,520 @@ +""" +auth.py — Shared authentication & encryption module. + +Responsibilities: +- Derive or generate a master secret key (persisted to disk with chmod 600) +- Provide Fernet-based encryption for user PII at rest +- Argon2id password hashing (constant-time verification) +- TOTP secret generation, QR provisioning URI, 6-digit code verification +- JWT session tokens (signed with master key) +- User CRUD on the shared SQLite database +- FastAPI dependencies to enforce authentication and admin role +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import os +import secrets +import sqlite3 +import time +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Optional + +import jwt +import pyotp +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError, InvalidHashError +from cryptography.fernet import Fernet, InvalidToken +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from fastapi import Depends, HTTPException, Request, status + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +DATA_DIR = Path(os.environ.get("DATA_DIR", "/app/data")) +DATA_DIR.mkdir(parents=True, exist_ok=True) + +SECRET_KEY_PATH = DATA_DIR / "secret.key" +DB_PATH = DATA_DIR / "pst_index.db" + +SESSION_TTL_SECONDS = int(os.environ.get("SESSION_TTL_SECONDS", 60 * 60 * 8)) # 8 hours +SESSION_COOKIE_NAME = "pst_session" +ADMIN_SESSION_COOKIE_NAME = "pst_admin_session" + +JWT_ALGORITHM = "HS256" + +# --------------------------------------------------------------------------- +# Master secret +# --------------------------------------------------------------------------- +def _load_or_create_master_secret() -> bytes: + """Return a 32-byte master secret. Created on first run, persisted to disk.""" + # Allow overriding via env var (useful for orchestration); still persist so + # restarts without the env var keep working. + env_secret = os.environ.get("MASTER_SECRET") + if env_secret: + return hashlib.sha256(env_secret.encode("utf-8")).digest() + + if SECRET_KEY_PATH.exists(): + data = SECRET_KEY_PATH.read_bytes() + if len(data) == 32: + return data + # Legacy or truncated: rewrap + return hashlib.sha256(data).digest() + + secret = secrets.token_bytes(32) + SECRET_KEY_PATH.write_bytes(secret) + try: + os.chmod(SECRET_KEY_PATH, 0o600) + except Exception: + pass + return secret + + +MASTER_SECRET: bytes = _load_or_create_master_secret() + + +def _derive_subkey(info: bytes, length: int = 32) -> bytes: + """Derive a sub-key from the master secret using HKDF-SHA256.""" + return HKDF( + algorithm=hashes.SHA256(), + length=length, + salt=b"pst-indexer-salt-v1", + info=info, + ).derive(MASTER_SECRET) + + +# Key used for encrypting user PII at rest +_FERNET_KEY = base64.urlsafe_b64encode(_derive_subkey(b"fernet-user-pii")) +_FERNET = Fernet(_FERNET_KEY) + +# Key used for JWT signatures +_JWT_KEY = _derive_subkey(b"jwt-sessions") + +# Key used for deterministic username lookup hashes (so we can index without +# exposing plaintext usernames in the DB). +_USERNAME_HMAC_KEY = _derive_subkey(b"username-lookup") + + +# --------------------------------------------------------------------------- +# Encryption helpers +# --------------------------------------------------------------------------- +def encrypt(plaintext: str) -> str: + """Fernet-encrypt a string; returns base64 ciphertext.""" + if plaintext is None: + return "" + return _FERNET.encrypt(plaintext.encode("utf-8")).decode("utf-8") + + +def decrypt(ciphertext: str) -> str: + """Fernet-decrypt. Returns empty string for empty input.""" + if not ciphertext: + return "" + try: + return _FERNET.decrypt(ciphertext.encode("utf-8")).decode("utf-8") + except InvalidToken: + raise ValueError("Failed to decrypt — master key may have changed") + + +def username_lookup_hash(username: str) -> str: + """Deterministic HMAC-SHA256 over the normalized username, hex-encoded.""" + normalized = username.strip().lower().encode("utf-8") + return hmac.new(_USERNAME_HMAC_KEY, normalized, hashlib.sha256).hexdigest() + + +# --------------------------------------------------------------------------- +# Password hashing +# --------------------------------------------------------------------------- +_hasher = PasswordHasher( + time_cost=3, + memory_cost=64 * 1024, # 64 MiB + parallelism=2, +) + + +def hash_password(password: str) -> str: + return _hasher.hash(password) + + +def verify_password(stored_hash: str, password: str) -> bool: + try: + _hasher.verify(stored_hash, password) + return True + except (VerifyMismatchError, InvalidHashError): + return False + except Exception: + return False + + +# --------------------------------------------------------------------------- +# TOTP (MFA) +# --------------------------------------------------------------------------- +def generate_totp_secret() -> str: + return pyotp.random_base32() + + +def totp_provisioning_uri(secret: str, username: str, issuer: str = "PST Archive") -> str: + return pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=issuer) + + +def verify_totp(secret: str, code: str) -> bool: + if not secret or not code: + return False + try: + return pyotp.TOTP(secret).verify(code.strip(), valid_window=1) + except Exception: + return False + + +# --------------------------------------------------------------------------- +# Database +# --------------------------------------------------------------------------- +@contextmanager +def get_db(): + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + try: + yield conn + finally: + conn.close() + + +def init_auth_schema(): + with get_db() as conn: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username_hash TEXT NOT NULL UNIQUE, + username_enc TEXT NOT NULL, + password_hash_enc TEXT NOT NULL, + totp_secret_enc TEXT, + mfa_enabled INTEGER NOT NULL DEFAULT 0, + role TEXT NOT NULL DEFAULT 'user', + created_at TEXT NOT NULL, + last_login TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_users_username_hash ON users(username_hash); + CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); + """) + conn.commit() + + +# --------------------------------------------------------------------------- +# User model +# --------------------------------------------------------------------------- +@dataclass +class User: + id: str + username: str + role: str + mfa_enabled: bool + created_at: str + last_login: Optional[str] + + @property + def is_admin(self) -> bool: + return self.role == "admin" + + def public_dict(self) -> dict: + return { + "id": self.id, + "username": self.username, + "role": self.role, + "mfa_enabled": self.mfa_enabled, + "created_at": self.created_at, + "last_login": self.last_login, + } + + +def _row_to_user(row: sqlite3.Row) -> User: + return User( + id=row["id"], + username=decrypt(row["username_enc"]), + role=row["role"], + mfa_enabled=bool(row["mfa_enabled"]), + created_at=row["created_at"], + last_login=row["last_login"], + ) + + +def count_users() -> int: + with get_db() as conn: + return conn.execute("SELECT COUNT(*) AS c FROM users").fetchone()["c"] + + +def count_admins() -> int: + with get_db() as conn: + return conn.execute("SELECT COUNT(*) AS c FROM users WHERE role='admin'").fetchone()["c"] + + +def find_user_by_username(username: str) -> Optional[tuple[User, str]]: + """Return (User, decrypted_password_hash) if found, else None.""" + uh = username_lookup_hash(username) + with get_db() as conn: + row = conn.execute( + "SELECT * FROM users WHERE username_hash = ?", (uh,) + ).fetchone() + if not row: + return None + user = _row_to_user(row) + try: + pw_hash = decrypt(row["password_hash_enc"]) + except ValueError: + return None + return user, pw_hash + + +def find_user_by_id(user_id: str) -> Optional[User]: + with get_db() as conn: + row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + if not row: + return None + return _row_to_user(row) + + +def get_totp_secret(user_id: str) -> Optional[str]: + with get_db() as conn: + row = conn.execute( + "SELECT totp_secret_enc FROM users WHERE id = ?", (user_id,) + ).fetchone() + if not row or not row["totp_secret_enc"]: + return None + try: + return decrypt(row["totp_secret_enc"]) + except ValueError: + return None + + +def list_users() -> list[User]: + with get_db() as conn: + rows = conn.execute( + "SELECT * FROM users ORDER BY created_at ASC" + ).fetchall() + return [_row_to_user(r) for r in rows] + + +def create_user(username: str, password: str, role: str = "user") -> User: + username = username.strip() + if not username or len(username) < 3: + raise ValueError("Username must be at least 3 characters") + if len(username) > 64: + raise ValueError("Username too long") + if role not in ("user", "admin"): + raise ValueError("Invalid role") + if len(password) < 8: + raise ValueError("Password must be at least 8 characters") + + uh = username_lookup_hash(username) + with get_db() as conn: + existing = conn.execute( + "SELECT id FROM users WHERE username_hash = ?", (uh,) + ).fetchone() + if existing: + raise ValueError("Username already taken") + + user_id = secrets.token_hex(16) + now = datetime.now(timezone.utc).isoformat() + pw_hash = hash_password(password) + + conn.execute( + """INSERT INTO users + (id, username_hash, username_enc, password_hash_enc, + totp_secret_enc, mfa_enabled, role, created_at, last_login) + VALUES (?, ?, ?, ?, NULL, 0, ?, ?, NULL)""", + (user_id, uh, encrypt(username), encrypt(pw_hash), role, now), + ) + conn.commit() + + return User( + id=user_id, username=username, role=role, mfa_enabled=False, + created_at=now, last_login=None, + ) + + +def delete_user(user_id: str) -> None: + with get_db() as conn: + # Also clean up any folder permission grants this user had + conn.execute("DELETE FROM folder_permissions WHERE user_id = ?", (user_id,)) + conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) + conn.commit() + + +def set_user_role(user_id: str, role: str) -> None: + if role not in ("user", "admin"): + raise ValueError("Invalid role") + with get_db() as conn: + conn.execute("UPDATE users SET role = ? WHERE id = ?", (role, user_id)) + conn.commit() + + +def update_password(user_id: str, new_password: str) -> None: + if len(new_password) < 8: + raise ValueError("Password must be at least 8 characters") + pw_hash = hash_password(new_password) + with get_db() as conn: + conn.execute( + "UPDATE users SET password_hash_enc = ? WHERE id = ?", + (encrypt(pw_hash), user_id), + ) + conn.commit() + + +def begin_mfa_enrollment(user_id: str) -> tuple[str, str]: + """Generate a new TOTP secret (not yet enabled) and return (secret, provisioning_uri).""" + user = find_user_by_id(user_id) + if not user: + raise ValueError("User not found") + secret = generate_totp_secret() + # Stash it but leave mfa_enabled = 0 until confirmed + with get_db() as conn: + conn.execute( + "UPDATE users SET totp_secret_enc = ?, mfa_enabled = 0 WHERE id = ?", + (encrypt(secret), user_id), + ) + conn.commit() + uri = totp_provisioning_uri(secret, user.username) + return secret, uri + + +def confirm_mfa_enrollment(user_id: str, code: str) -> bool: + secret = get_totp_secret(user_id) + if not secret or not verify_totp(secret, code): + return False + with get_db() as conn: + conn.execute("UPDATE users SET mfa_enabled = 1 WHERE id = ?", (user_id,)) + conn.commit() + return True + + +def disable_mfa(user_id: str) -> None: + with get_db() as conn: + conn.execute( + "UPDATE users SET mfa_enabled = 0, totp_secret_enc = NULL WHERE id = ?", + (user_id,), + ) + conn.commit() + + +def record_login(user_id: str) -> None: + with get_db() as conn: + conn.execute( + "UPDATE users SET last_login = ? WHERE id = ?", + (datetime.now(timezone.utc).isoformat(), user_id), + ) + conn.commit() + + +# --------------------------------------------------------------------------- +# JWT sessions +# --------------------------------------------------------------------------- +def create_session_token(user: User, scope: str = "app") -> str: + """scope='app' for main app, scope='admin' for admin panel.""" + now = datetime.now(timezone.utc) + payload = { + "sub": user.id, + "role": user.role, + "scope": scope, + "iat": int(now.timestamp()), + "exp": int((now + timedelta(seconds=SESSION_TTL_SECONDS)).timestamp()), + } + return jwt.encode(payload, _JWT_KEY, algorithm=JWT_ALGORITHM) + + +def verify_session_token(token: str, expected_scope: str) -> Optional[dict]: + try: + payload = jwt.decode(token, _JWT_KEY, algorithms=[JWT_ALGORITHM]) + if payload.get("scope") != expected_scope: + return None + return payload + except jwt.PyJWTError: + return None + + +# --------------------------------------------------------------------------- +# FastAPI dependencies +# --------------------------------------------------------------------------- +def _extract_token(request: Request, cookie_name: str) -> Optional[str]: + # Prefer cookie, fall back to Authorization header + token = request.cookies.get(cookie_name) + if token: + return token + auth = request.headers.get("authorization", "") + if auth.lower().startswith("bearer "): + return auth[7:].strip() + return None + + +def require_user(scope: str, cookie_name: str): + def _dep(request: Request) -> User: + token = _extract_token(request, cookie_name) + if not token: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not authenticated") + payload = verify_session_token(token, expected_scope=scope) + if not payload: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid or expired session") + user = find_user_by_id(payload["sub"]) + if not user: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User no longer exists") + return user + return _dep + + +def require_admin(scope: str, cookie_name: str): + base = require_user(scope, cookie_name) + def _dep(request: Request) -> User: + user = base(request) + if not user.is_admin: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin role required") + return user + return _dep + + +# Pre-wired dependencies for the two app scopes +require_app_user = require_user("app", SESSION_COOKIE_NAME) +require_admin_user = require_admin("admin", ADMIN_SESSION_COOKIE_NAME) + +# App-scoped admin dependency: authenticates against the MAIN app session +# cookie (scope "app") but additionally requires the admin role. This lets the +# administration section run inside the main application (served at /admin) +# instead of as a separate process on its own port, while still being strictly +# limited to admin users. +require_app_admin = require_admin("app", SESSION_COOKIE_NAME) + + +# --------------------------------------------------------------------------- +# Simple rate limiter (per-IP, per-username) to slow brute-force attempts +# --------------------------------------------------------------------------- +class RateLimiter: + def __init__(self, window_seconds: int = 300, max_attempts: int = 10): + self.window = window_seconds + self.max = max_attempts + self._attempts: dict[str, list[float]] = {} + + def check(self, key: str) -> bool: + now = time.time() + cutoff = now - self.window + arr = [t for t in self._attempts.get(key, []) if t > cutoff] + if len(arr) >= self.max: + self._attempts[key] = arr + return False + return True + + def record(self, key: str) -> None: + now = time.time() + cutoff = now - self.window + arr = [t for t in self._attempts.get(key, []) if t > cutoff] + arr.append(now) + self._attempts[key] = arr + + def reset(self, key: str) -> None: + self._attempts.pop(key, None) + + +login_limiter = RateLimiter(window_seconds=300, max_attempts=10) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..9cbd286 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.115.0 +uvicorn[standard]==0.32.0 +python-multipart==0.0.12 +libpff-python==20231205 +aiofiles==24.1.0 +argon2-cffi==23.1.0 +pyotp==2.9.0 +cryptography==43.0.1 +PyJWT==2.9.0 +qrcode[pil]==7.4.2 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d5504cc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + pst-indexer: + build: . + container_name: pst-indexer + ports: + - "8000:8000" # App + built-in administration (at /admin) + volumes: + - ./data:/app/data + restart: unless-stopped + environment: + - DATA_DIR=/app/data + # Set to "true" when serving over HTTPS + - COOKIE_SECURE=false + # Session lifetime in seconds (default 8 hours) + - SESSION_TTL_SECONDS=28800 + # Optional: provide your own master secret. If set, this becomes the + # root key for all encryption. If omitted, a random key is generated + # on first run and persisted in ./data/secret.key (chmod 600). + # - MASTER_SECRET=change-me-to-a-long-random-string diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..8e6b790 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Start the application (port 8000). The administration section now runs inside +# this same app (served at /admin), so there is no longer a separate admin +# process or port. + +set -e + +cleanup() { + echo "Shutting down…" + kill -TERM "$MAIN_PID" 2>/dev/null || true + wait + exit 0 +} +trap cleanup SIGINT SIGTERM + +cd /app/backend + +echo "[start] App (with built-in administration) on :8000" +uvicorn app:app --host 0.0.0.0 --port 8000 --proxy-headers & +MAIN_PID=$! + +wait "$MAIN_PID" +EXIT_CODE=$? +echo "[exit] Service exited with code $EXIT_CODE" +exit $EXIT_CODE diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..49cac5f --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,877 @@ +/* ============================================================ + PST Archive — frontend logic + - Setup wizard (first run) + - Login (+ MFA) + - Main app: upload, index, search, view + ============================================================ */ + +const API = "/api"; + +const state = { + activePstId: null, + activePstName: "", + folder: "", + query: "", + limit: 25, + offset: 0, + total: 0, + refreshTimer: null, + user: null, + folders: [], // [{id, name, file_count}, ...] + psts: [], // last loaded pst list + collapsedFolders: new Set(), // folder IDs (or "__uncat__") that are collapsed + movePstId: null, // currently being moved +}; + +const $ = (id) => document.getElementById(id); + +// ---------- helpers ---------- +function fmtBytes(n) { + if (!n) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let i = 0; + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } + return `${n.toFixed(i ? 1 : 0)} ${units[i]}`; +} + +function fmtDate(iso) { + if (!iso) return "—"; + try { + const d = new Date(iso); + if (isNaN(d)) return iso; + return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); + } catch { return iso; } +} + +function fmtDateTime(iso) { + if (!iso) return "—"; + try { + const d = new Date(iso); + if (isNaN(d)) return iso; + return d.toLocaleString("en-US", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); + } catch { return iso; } +} + +function toast(msg, ms = 3000) { + const el = $("toast"); + el.textContent = msg; + el.classList.remove("hidden"); + clearTimeout(toast._t); + toast._t = setTimeout(() => el.classList.add("hidden"), ms); +} + +function escapeHtml(s) { + if (s == null) return ""; + return String(s) + .replace(/&/g, "&").replace(//g, ">") + .replace(/"/g, """).replace(/'/g, "'"); +} + +function renderSnippet(snippet) { + if (!snippet) return ""; + return escapeHtml(snippet) + .replace(/<mark>/g, "") + .replace(/<\/mark>/g, ""); +} + +function debounce(fn, ms) { + let t; + return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; +} + +// ---------- API ---------- +async function api(path, opts = {}) { + const res = await fetch(API + path, { + credentials: "same-origin", + ...opts, + }); + let body = null; + try { body = await res.json(); } catch {} + if (!res.ok) { + const err = new Error((body && body.detail) || res.statusText); + err.status = res.status; + err.body = body || {}; + throw err; + } + return body; +} + +// ---------- Screen switching ---------- +function showScreen(screen) { + ["setupScreen", "loginScreen", "mainApp"].forEach((id) => { + $(id).classList.toggle("hidden", id !== screen); + }); +} + +// ---------- Bootstrap ---------- +async function bootstrap() { + // 1. Try to fetch current user + try { + const { user } = await api("/me"); + state.user = user; + enterApp(); + return; + } catch (e) { + if (e.status !== 401) { + showLogin(); + toast("Connection problem — please retry."); + return; + } + } + + // 2. Not logged in — check setup status + try { + const { needs_setup } = await api("/setup/status"); + if (needs_setup) showSetup(); + else showLogin(); + } catch { + showLogin(); + } +} + +// ---------- Setup ---------- +function showSetup() { + showScreen("setupScreen"); + $("setupForm").reset(); + $("setupError").classList.add("hidden"); + setTimeout(() => $("setupForm").username?.focus(), 50); +} + +async function handleSetup(e) { + e.preventDefault(); + const form = e.target; + const errEl = $("setupError"); + errEl.classList.add("hidden"); + + const username = form.username.value.trim(); + const password = form.password.value; + const password2 = form.password2.value; + + if (password !== password2) { + errEl.textContent = "Passwords do not match."; + errEl.classList.remove("hidden"); + return; + } + + try { + const { user } = await api("/setup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + state.user = user; + toast("Welcome — admin account created."); + enterApp(); + } catch (err) { + errEl.textContent = err.message || "Setup failed."; + errEl.classList.remove("hidden"); + } +} + +// ---------- Login ---------- +function showLogin() { + showScreen("loginScreen"); + $("loginForm").reset(); + $("mfaField").classList.add("hidden"); + $("loginError").classList.add("hidden"); + setTimeout(() => $("loginForm").username?.focus(), 50); +} + +async function handleLogin(e) { + e.preventDefault(); + const form = e.target; + const errEl = $("loginError"); + errEl.classList.add("hidden"); + + const payload = { + username: form.username.value.trim(), + password: form.password.value, + }; + const mfaInput = form.totp_code.value.trim(); + if (mfaInput) payload.totp_code = mfaInput; + + try { + const { user } = await api("/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + state.user = user; + toast(`Welcome, ${user.username}.`); + enterApp(); + } catch (err) { + if (err.status === 401 && err.body && err.body.mfa_required) { + $("mfaField").classList.remove("hidden"); + const f = $("loginForm").totp_code; + f.value = ""; + setTimeout(() => f.focus(), 50); + errEl.textContent = "Enter your authenticator code to continue."; + errEl.classList.remove("hidden"); + return; + } + errEl.textContent = err.message || "Login failed."; + errEl.classList.remove("hidden"); + } +} + +async function handleLogout() { + try { await api("/logout", { method: "POST" }); } catch {} + state.user = null; + state.activePstId = null; + resetReader(); + showLogin(); +} + +// ---------- Enter the app ---------- +function enterApp() { + showScreen("mainApp"); + $("userChip").textContent = `${state.user.username}${state.user.role === "admin" ? " · admin" : ""}`; + // Show the ADMINISTRATION button only for admins. + const adminBtn = $("adminNavBtn"); + if (adminBtn) adminBtn.classList.toggle("hidden", state.user.role !== "admin"); + checkHealth(); + loadPstList(); + setInterval(checkHealth, 30000); +} + +// ---------- Health ---------- +async function checkHealth() { + const dot = $("statusDot"); + const text = $("statusText"); + try { + await api("/health"); + dot.className = "status-dot ok"; + text.textContent = "connected"; + } catch { + dot.className = "status-dot err"; + text.textContent = "offline"; + } +} + +// ---------- PST list ---------- +async function loadPstList() { + try { + const [folders, items] = await Promise.all([ + api("/folders"), + api("/pst-files"), + ]); + state.folders = folders; + state.psts = items; + renderPstList(); + if (items.some((i) => i.status === "indexing")) { + clearTimeout(state.refreshTimer); + state.refreshTimer = setTimeout(loadPstList, 2500); + } + } catch (e) { + if (e.status === 401) { handleLogout(); return; } + toast(`Failed to load archives: ${e.message}`); + } +} + +function pstItemHtml(i) { + const active = i.id === state.activePstId ? "active" : ""; + const disabled = i.status !== "ready"; + const statusText = i.status === "indexing" ? "indexing…" : + i.status === "ready" ? `${i.message_count.toLocaleString()} msgs` : + i.status === "failed" ? "failed" : i.status; + return ` +
  • +
    + ${escapeHtml(i.original_name)} + +
    +
    + ${fmtBytes(i.size_bytes)} · ${fmtDate(i.uploaded_at)} + ${statusText} +
    +
  • + `; +} + +function renderPstList() { + const ul = $("pstList"); + const { folders, psts } = state; + + if (!folders.length && !psts.length) { + ul.innerHTML = '
  • No archives yet.
  • '; + return; + } + + // Group PSTs by folder_id + const byFolder = new Map(); + byFolder.set(null, []); // uncategorized bucket + folders.forEach((f) => byFolder.set(f.id, [])); + psts.forEach((p) => { + const bucket = byFolder.has(p.folder_id) ? p.folder_id : null; + byFolder.get(bucket).push(p); + }); + + const parts = []; + + // Render user-defined folders first (sorted alphabetically — server does this) + for (const f of folders) { + const items = byFolder.get(f.id) || []; + const collapsed = state.collapsedFolders.has(f.id) ? "collapsed" : ""; + parts.push(` +
  • +
    +
    + + ${escapeHtml(f.name)} + ${items.length} +
    + +
    +
      + ${items.length + ? items.map(pstItemHtml).join("") + : '
    • — empty —
    • '} +
    +
  • + `); + } + + // Uncategorized group — only shown if it has items OR there are no folders + const uncat = byFolder.get(null) || []; + if (uncat.length || !folders.length) { + const collapsed = state.collapsedFolders.has("__uncat__") ? "collapsed" : ""; + parts.push(` +
  • +
    +
    + + Uncategorized + ${uncat.length} +
    +
    +
      + ${uncat.length + ? uncat.map(pstItemHtml).join("") + : '
    • — empty —
    • '} +
    +
  • + `); + } + + ul.innerHTML = parts.join(""); + + // PST click + ul.querySelectorAll(".pst-item").forEach((el) => { + el.addEventListener("click", (e) => { + if (e.target.closest("[data-pst-menu]")) return; + if (el.dataset.disabled) { + toast(el.dataset.status === "indexing" ? "Still indexing — one moment…" : "This archive is unavailable."); + return; + } + selectPst(el.dataset.id, el.dataset.name); + }); + }); + + // PST action menu + ul.querySelectorAll("[data-pst-menu]").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + openPstMenu(e, btn.dataset.pstMenu); + }); + }); + + // Folder header toggle (collapse/expand) + ul.querySelectorAll("[data-folder-header]").forEach((el) => { + el.addEventListener("click", (e) => { + if (e.target.closest("[data-folder-menu]")) return; + const id = el.dataset.folderHeader; + if (state.collapsedFolders.has(id)) state.collapsedFolders.delete(id); + else state.collapsedFolders.add(id); + const group = el.closest(".folder-group"); + group.classList.toggle("collapsed"); + }); + }); + + // Folder action menu + ul.querySelectorAll("[data-folder-menu]").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + openFolderMenu(e, btn.dataset.folderMenu); + }); + }); +} + +// ---------- Context menus ---------- +function closeContextMenu() { + document.querySelectorAll(".context-menu").forEach((el) => el.remove()); +} + +function openContextMenu(x, y, items) { + closeContextMenu(); + const menu = document.createElement("div"); + menu.className = "context-menu"; + items.forEach((it) => { + if (it.divider) { + menu.appendChild(document.createElement("hr")); + return; + } + const btn = document.createElement("button"); + btn.textContent = it.label; + if (it.danger) btn.classList.add("danger"); + btn.addEventListener("click", () => { + closeContextMenu(); + it.onClick(); + }); + menu.appendChild(btn); + }); + document.body.appendChild(menu); + + // Position, keeping menu inside viewport + const rect = menu.getBoundingClientRect(); + const maxX = window.innerWidth - rect.width - 4; + const maxY = window.innerHeight - rect.height - 4; + menu.style.left = Math.min(x, maxX) + "px"; + menu.style.top = Math.min(y, maxY) + "px"; + + // Close on outside click or escape + setTimeout(() => { + document.addEventListener("click", closeContextMenu, { once: true }); + }, 0); +} + +function openPstMenu(event, pstId) { + const p = state.psts.find((x) => x.id === pstId); + if (!p) return; + const r = event.currentTarget.getBoundingClientRect(); + openContextMenu(r.left, r.bottom + 4, [ + { + label: "Move to folder…", + onClick: () => openMoveModal(p), + }, + { divider: true }, + { + label: "Delete archive", + danger: true, + onClick: () => deletePst(p), + }, + ]); +} + +function openFolderMenu(event, folderId) { + const f = state.folders.find((x) => x.id === folderId); + if (!f) return; + const r = event.currentTarget.getBoundingClientRect(); + openContextMenu(r.left, r.bottom + 4, [ + { + label: "Rename folder…", + onClick: () => renameFolder(f), + }, + { divider: true }, + { + label: "Delete folder", + danger: true, + onClick: () => deleteFolder(f), + }, + ]); +} + +// ---------- Folder operations ---------- +function openFolderModal() { + $("folderForm").reset(); + $("folderError").classList.add("hidden"); + $("folderModal").classList.remove("hidden"); + setTimeout(() => $("folderForm").name.focus(), 50); +} + +async function handleCreateFolder(e) { + e.preventDefault(); + const form = e.target; + const errEl = $("folderError"); + errEl.classList.add("hidden"); + try { + await api("/folders", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: form.name.value }), + }); + toast("Folder created."); + closeModals(); + loadPstList(); + } catch (err) { + errEl.textContent = err.message || "Create failed."; + errEl.classList.remove("hidden"); + } +} + +async function renameFolder(f) { + const newName = prompt(`Rename "${f.name}" to:`, f.name); + if (!newName || newName.trim() === f.name) return; + try { + await api(`/folders/${f.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: newName }), + }); + toast("Folder renamed."); + loadPstList(); + } catch (err) { + toast(`Rename failed: ${err.message}`); + } +} + +async function deleteFolder(f) { + if (!confirm(`Delete folder "${f.name}"?\n\nAny archives inside will be moved to Uncategorized.`)) return; + try { + await api(`/folders/${f.id}`, { method: "DELETE" }); + toast("Folder deleted."); + loadPstList(); + } catch (err) { + toast(`Delete failed: ${err.message}`); + } +} + +async function deletePst(p) { + if (!confirm(`Remove "${p.original_name}" and all indexed messages?`)) return; + try { + await api(`/pst-files/${p.id}`, { method: "DELETE" }); + if (state.activePstId === p.id) { state.activePstId = null; resetReader(); } + toast("Archive removed."); + loadPstList(); + } catch (err) { + toast(`Delete failed: ${err.message}`); + } +} + +function openMoveModal(p) { + state.movePstId = p.id; + $("moveSubject").textContent = p.original_name; + + const sel = $("moveFolderSelect"); + sel.innerHTML = '' + + state.folders.map((f) => + `` + ).join(""); + if (!p.folder_id) sel.value = ""; + + $("moveError").classList.add("hidden"); + $("moveModal").classList.remove("hidden"); +} + +async function handleMovePst(e) { + e.preventDefault(); + const errEl = $("moveError"); + errEl.classList.add("hidden"); + const folderId = $("moveFolderSelect").value || null; + try { + await api(`/pst-files/${state.movePstId}/folder`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ folder_id: folderId }), + }); + toast("Moved."); + closeModals(); + loadPstList(); + } catch (err) { + errEl.textContent = err.message || "Move failed."; + errEl.classList.remove("hidden"); + } +} + +function closeModals() { + document.querySelectorAll(".modal").forEach((m) => m.classList.add("hidden")); +} + +// ---------- Selection + folders ---------- +async function selectPst(id, name) { + state.activePstId = id; + state.activePstName = name; + state.folder = ""; + state.query = ""; + state.offset = 0; + + $("searchInput").value = ""; + $("searchInput").disabled = false; + $("folderFilter").disabled = false; + + document.querySelectorAll(".pst-item").forEach((el) => { + el.classList.toggle("active", el.dataset.id === id); + }); + $("readerTitle").textContent = name; + $("readerMeta").textContent = "Loading messages…"; + hideMessageView(); + await Promise.all([loadFolders(), loadMessages()]); +} + +async function loadFolders() { + if (!state.activePstId) return; + try { + const folders = await api(`/pst-files/${state.activePstId}/folders`); + const sel = $("folderFilter"); + sel.innerHTML = + '' + + folders.map((f) => ``).join(""); + sel.value = state.folder || ""; + } catch (e) { console.warn("folders", e); } +} + +// ---------- Messages ---------- +async function loadMessages() { + if (!state.activePstId) return; + const params = new URLSearchParams(); + if (state.query) params.set("q", state.query); + if (state.folder) params.set("folder", state.folder); + params.set("limit", state.limit); + params.set("offset", state.offset); + + try { + const res = await api(`/pst-files/${state.activePstId}/messages?${params}`); + state.total = res.total; + renderMessages(res.items); + renderPaginator(); + $("readerMeta").textContent = + `${state.total.toLocaleString()} message${state.total === 1 ? "" : "s"}` + + (state.query ? ` matching "${state.query}"` : "") + + (state.folder ? ` in ${state.folder}` : ""); + } catch (e) { + if (e.status === 401) { handleLogout(); return; } + toast(`Search failed: ${e.message}`); + } +} + +function renderMessages(items) { + const list = $("messageList"); + if (!items.length) { + list.innerHTML = ` +
    +
    +

    No messages match.

    +
    `; + return; + } + list.innerHTML = items.map((m) => { + const hasAtt = m.attachments && m.attachments.trim().length > 0; + return ` +
    +
    ${escapeHtml(m.sender || "(unknown sender)")}
    +
    +
    ${escapeHtml(m.subject || "(no subject)")} ${hasAtt ? '' : ""}
    +
    ${renderSnippet(m.snippet || "")}
    +
    +
    +
    ${fmtDate(m.sent_date)}
    +
    ${escapeHtml((m.folder || "").split("/").filter(Boolean).pop() || "inbox")}
    +
    +
    `; + }).join(""); + list.querySelectorAll(".message-row").forEach((el) => { + el.addEventListener("click", () => openMessage(el.dataset.id)); + }); +} + +function renderPaginator() { + const p = $("paginator"); + if (state.total <= state.limit) { p.classList.add("hidden"); return; } + p.classList.remove("hidden"); + const from = state.offset + 1; + const to = Math.min(state.offset + state.limit, state.total); + $("pageInfo").textContent = `${from}–${to} of ${state.total.toLocaleString()}`; + $("prevBtn").disabled = state.offset === 0; + $("nextBtn").disabled = state.offset + state.limit >= state.total; +} + +// ---------- Message view ---------- +async function openMessage(id) { + try { + const m = await api(`/messages/${id}`); + $("mvSubject").textContent = m.subject || "(no subject)"; + $("mvSender").textContent = m.sender || "—"; + $("mvRecipients").textContent = m.recipients || "—"; + $("mvDate").textContent = fmtDateTime(m.sent_date); + $("mvFolder").textContent = m.folder || "—"; + if (m.attachments && m.attachments.trim()) { + $("mvAttLabel").classList.remove("hidden"); + $("mvAttachments").classList.remove("hidden"); + $("mvAttachments").textContent = m.attachments.split(" | ").join(", "); + } else { + $("mvAttLabel").classList.add("hidden"); + $("mvAttachments").classList.add("hidden"); + } + $("mvBody").textContent = m.body || "(empty body)"; + $("messageView").classList.remove("hidden"); + } catch (e) { + if (e.status === 401) { handleLogout(); return; } + toast(`Could not open message: ${e.message}`); + } +} + +function hideMessageView() { $("messageView").classList.add("hidden"); } + +function resetReader() { + $("readerTitle").textContent = "Select an archive"; + $("readerMeta").textContent = "Upload a PST file or select one from the left to begin."; + $("searchInput").disabled = true; + $("folderFilter").disabled = true; + $("messageList").innerHTML = ` +
    +
    +

    The archive awaits.

    +
    `; + $("paginator").classList.add("hidden"); + hideMessageView(); +} + +// ---------- Upload ---------- +function uploadFile(file) { + if (!file) return; + const lower = file.name.toLowerCase(); + if (!lower.endsWith(".pst") && !lower.endsWith(".ost")) { + toast("Only .pst and .ost files are accepted."); + return; + } + const progress = $("uploadProgress"); + const bar = $("progressBar"); + const pct = $("progressPct"); + const nm = $("progressName"); + progress.classList.remove("hidden"); + nm.textContent = file.name; + bar.style.width = "0%"; + pct.textContent = "0%"; + + const form = new FormData(); + form.append("file", file); + + const xhr = new XMLHttpRequest(); + xhr.open("POST", API + "/pst-files"); + xhr.withCredentials = true; + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) { + const p = Math.round((e.loaded / e.total) * 100); + bar.style.width = p + "%"; + pct.textContent = p + "%"; + } + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + pct.textContent = "indexing…"; + bar.style.width = "100%"; + toast("Upload complete. Indexing started in background."); + setTimeout(() => progress.classList.add("hidden"), 2500); + loadPstList(); + } else if (xhr.status === 401) { + handleLogout(); + } else { + let msg = "Upload failed"; + try { msg = JSON.parse(xhr.responseText).detail || msg; } catch {} + toast(msg); + progress.classList.add("hidden"); + } + }; + xhr.onerror = () => { toast("Upload failed (network)."); progress.classList.add("hidden"); }; + xhr.send(form); +} + +// ---------- Theme ---------- +function initTheme() { + // Preference order: localStorage → prefers-color-scheme → light + let theme = "light"; + try { + const saved = localStorage.getItem("pst-theme"); + if (saved === "light" || saved === "dark") { + theme = saved; + } else if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) { + theme = "dark"; + } + } catch { /* ignore storage errors */ } + document.documentElement.dataset.theme = theme; +} + +function toggleTheme() { + const current = document.documentElement.dataset.theme === "dark" ? "dark" : "light"; + const next = current === "dark" ? "light" : "dark"; + document.documentElement.dataset.theme = next; + try { localStorage.setItem("pst-theme", next); } catch {} +} + +// Apply theme immediately so there's no flash of wrong theme +initTheme(); + +// ---------- Wire up ---------- +function init() { + $("setupForm").addEventListener("submit", handleSetup); + $("loginForm").addEventListener("submit", handleLogin); + $("logoutBtn").addEventListener("click", handleLogout); + + const adminNavBtn = $("adminNavBtn"); + if (adminNavBtn) { + adminNavBtn.addEventListener("click", () => { window.location.href = "/admin/"; }); + } + + // Theme toggles (both the in-masthead and floating variants) + ["themeToggle", "themeToggleFloat"].forEach((id) => { + const el = document.getElementById(id); + if (el) el.addEventListener("click", toggleTheme); + }); + + $("fileInput").addEventListener("change", (e) => { + const f = e.target.files[0]; + if (f) uploadFile(f); + e.target.value = ""; + }); + + const dz = $("dropzone"); + ["dragenter", "dragover"].forEach((ev) => { + dz.addEventListener(ev, (e) => { e.preventDefault(); dz.classList.add("drag-over"); }); + }); + ["dragleave", "drop"].forEach((ev) => { + dz.addEventListener(ev, (e) => { e.preventDefault(); dz.classList.remove("drag-over"); }); + }); + dz.addEventListener("drop", (e) => { + const f = e.dataTransfer.files?.[0]; + if (f) uploadFile(f); + }); + + $("refreshBtn").addEventListener("click", loadPstList); + $("newFolderBtn").addEventListener("click", openFolderModal); + $("folderForm").addEventListener("submit", handleCreateFolder); + $("moveForm").addEventListener("submit", handleMovePst); + + // Modal close buttons + backdrop clicks + Escape + document.querySelectorAll("[data-close]").forEach((el) => { + el.addEventListener("click", closeModals); + }); + document.querySelectorAll(".modal").forEach((m) => { + m.addEventListener("click", (e) => { + if (e.target === m) closeModals(); + }); + }); + + const searchHandler = debounce(() => { + state.query = $("searchInput").value.trim(); + state.offset = 0; + loadMessages(); + }, 250); + $("searchInput").addEventListener("input", searchHandler); + + $("folderFilter").addEventListener("change", () => { + state.folder = $("folderFilter").value; + state.offset = 0; + loadMessages(); + }); + + $("prevBtn").addEventListener("click", () => { + state.offset = Math.max(0, state.offset - state.limit); + loadMessages(); + }); + $("nextBtn").addEventListener("click", () => { + state.offset += state.limit; + loadMessages(); + }); + + $("closeMessageBtn").addEventListener("click", hideMessageView); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + hideMessageView(); + closeModals(); + closeContextMenu(); + } + }); + + bootstrap(); +} + +document.addEventListener("DOMContentLoaded", init); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..76b9585 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,259 @@ + + + + + +PST Archive · Mail Indexer + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/styles.css b/frontend/styles.css new file mode 100644 index 0000000..63db6c0 --- /dev/null +++ b/frontend/styles.css @@ -0,0 +1,1182 @@ +/* ============================================================ + PST ARCHIVE — aesthetic: editorial/forensic archive + Warm paper palette, serif display, mono accents, grain overlay + Supports light (default) and dark themes via [data-theme] + ============================================================ */ + +:root, +:root[data-theme="light"] { + --paper: #f2ede3; + --paper-deep: #e8e1d3; + --ink: #1b1814; + --ink-soft: #3a332a; + --ink-muted: #6b6258; + --rule: #c9bfad; + --rule-soft: #dcd3c1; + --accent: #a8341f; /* oxblood red */ + --accent-soft: #c95a42; + --highlight: #e8d67a; /* faded yellow marker */ + --ok: #2e6f3a; + --warn: #a86b1e; + --shadow: 0 1px 0 rgba(27, 24, 20, 0.04), 0 20px 40px -20px rgba(27, 24, 20, 0.25); + --grain-opacity: 0.35; + + --font-display: "Fraunces", "Iowan Old Style", "Palatino", Georgia, serif; + --font-mono: "JetBrains Mono", "SFMono-Regular", ui-monospace, monospace; +} + +:root[data-theme="dark"] { + --paper: #17140f; /* deep inked paper */ + --paper-deep: #201c16; /* slightly lighter ink-pool for surfaces */ + --ink: #e9e0cb; /* warm paper-cream as the ink color */ + --ink-soft: #c4b99f; + --ink-muted: #8a8170; + --rule: #3a3226; + --rule-soft: #2a251d; + --accent: #d06347; /* softened oxblood that holds up on dark */ + --accent-soft: #e4876c; + --highlight: #d8b64a; /* brass-yellow marker */ + --ok: #6fae7c; + --warn: #d99a4a; + --shadow: 0 1px 0 rgba(0, 0, 0, 0.4), 0 20px 40px -20px rgba(0, 0, 0, 0.9); + --grain-opacity: 0.12; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + height: 100%; + background: var(--paper); + color: var(--ink); + font-family: var(--font-display); + font-feature-settings: "ss01", "onum"; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + display: flex; + flex-direction: column; + min-height: 100vh; + background-image: + radial-gradient(circle at 10% 10%, rgba(168, 52, 31, 0.04), transparent 40%), + radial-gradient(circle at 90% 90%, rgba(74, 58, 42, 0.05), transparent 50%); +} + +:root[data-theme="dark"] body { + background-image: + radial-gradient(circle at 10% 10%, rgba(208, 99, 71, 0.06), transparent 40%), + radial-gradient(circle at 90% 90%, rgba(216, 182, 74, 0.03), transparent 50%); +} + +/* grain overlay */ +.grain { + pointer-events: none; + position: fixed; + inset: 0; + z-index: 9999; + opacity: var(--grain-opacity); + mix-blend-mode: multiply; + background-image: url("data:image/svg+xml;utf8,"); +} + +:root[data-theme="dark"] .grain { + mix-blend-mode: screen; +} + +/* ============================================================ + MASTHEAD + ============================================================ */ +.masthead { + display: flex; + justify-content: space-between; + align-items: flex-end; + padding: 28px 40px 18px; + border-bottom: 1px solid var(--ink); + position: relative; +} + +.masthead::after { + content: ""; + position: absolute; + bottom: -4px; + left: 40px; + right: 40px; + height: 1px; + background: var(--ink); +} + +.masthead-left { display: flex; align-items: center; gap: 18px; } + +.logo-mark { + color: var(--ink); + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border: 1px solid var(--ink); + background: var(--paper); +} + +.masthead-title h1 { + margin: 0; + font-size: 2.5rem; + font-weight: 500; + letter-spacing: -0.02em; + line-height: 1; + font-variation-settings: "SOFT" 30, "WONK" 1; +} + +.tagline { + margin: 6px 0 0; + font-family: var(--font-mono); + font-size: 0.7rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--ink-muted); +} + +.masthead-right { + display: flex; + align-items: center; + gap: 10px; + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-muted); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--warn); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 25%, transparent); +} +.status-dot.ok { background: var(--ok); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 25%, transparent); } +.status-dot.err { background: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent); } + +/* ============================================================ + WORKSPACE + ============================================================ */ +.workspace { + display: grid; + grid-template-columns: 340px 1fr; + flex: 1; + min-height: 0; +} + +/* ============================================================ + SIDEBAR + ============================================================ */ +.sidebar { + border-right: 1px solid var(--rule); + padding: 28px 24px; + display: flex; + flex-direction: column; + gap: 36px; + overflow-y: auto; + background: linear-gradient(180deg, rgba(232, 225, 211, 0.4), transparent 40%); +} + +.section-heading { + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ink-soft); + margin: 0 0 14px; + padding-bottom: 8px; + border-bottom: 1px solid var(--rule); + display: flex; + align-items: baseline; + justify-content: space-between; +} + +.dropzone { + display: block; + border: 1.5px dashed var(--ink-muted); + background: var(--paper-deep); + padding: 28px 18px; + text-align: center; + cursor: pointer; + transition: all 0.2s ease; + position: relative; +} +.dropzone:hover, +.dropzone.drag-over { + border-color: var(--accent); + background: var(--rule-soft); + transform: translateY(-1px); +} +.dropzone-inner { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + color: var(--ink-soft); +} +.dropzone-title { + font-family: var(--font-display); + font-size: 1.1rem; + font-style: italic; + font-weight: 400; +} +.dropzone-title em { + font-style: normal; + font-family: var(--font-mono); + font-size: 0.9rem; + color: var(--accent); +} +.dropzone-hint { + font-family: var(--font-mono); + font-size: 0.65rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--ink-muted); +} + +.upload-progress { margin-top: 14px; } +.progress-label { + display: flex; + justify-content: space-between; + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--ink-muted); + margin-bottom: 6px; +} +.progress-track { + height: 4px; + background: var(--rule-soft); + overflow: hidden; +} +.progress-bar { + height: 100%; + width: 0%; + background: var(--accent); + transition: width 0.2s ease; +} + +.archive-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; +} +.archive-head .section-heading { + margin: 0; + border: none; + padding: 0; +} + +.icon-btn { + background: transparent; + border: 1px solid var(--rule); + padding: 5px 8px; + cursor: pointer; + color: var(--ink-soft); + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; +} +.icon-btn:hover { + border-color: var(--ink); + color: var(--accent); +} + +.pst-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; } + +.pst-item { + padding: 14px 14px; + border: 1px solid var(--rule); + background: var(--paper); + cursor: pointer; + transition: all 0.15s ease; + position: relative; +} +.pst-item:hover { + border-color: var(--ink); + transform: translateX(2px); +} +.pst-item.active { + border-color: var(--ink); + background: var(--ink); + color: var(--paper); + box-shadow: 4px 4px 0 var(--accent); +} +.pst-item.active .pst-meta { color: color-mix(in srgb, var(--paper) 65%, transparent); } + +.pst-name { + font-size: 1rem; + font-weight: 500; + letter-spacing: -0.01em; + margin: 0 0 6px; + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; +} +.pst-name-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.pst-menu { + background: none; + border: none; + color: inherit; + opacity: 0.4; + cursor: pointer; + font-size: 1.1rem; + line-height: 1; + padding: 0 4px; + letter-spacing: 0.1em; + transition: opacity 0.15s, color 0.15s; +} +.pst-menu:hover { opacity: 1; color: var(--accent); } +.pst-item.active .pst-menu:hover { color: var(--accent-soft); } + +.pst-meta { + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.08em; + color: var(--ink-muted); + display: flex; + justify-content: space-between; + gap: 8px; +} + +.pst-status { + text-transform: uppercase; + letter-spacing: 0.12em; +} +.pst-status.indexing { color: var(--warn); } +.pst-status.ready { color: var(--ok); } +.pst-status.failed { color: var(--accent); } +.pst-item.active .pst-status.indexing { color: color-mix(in srgb, var(--warn) 70%, var(--paper) 30%); } +.pst-item.active .pst-status.ready { color: color-mix(in srgb, var(--ok) 70%, var(--paper) 30%); } +.pst-item.active .pst-status.failed { color: var(--accent-soft); } + +.empty-state { + color: var(--ink-muted); + font-style: italic; + text-align: center; + padding: 20px 0; + font-size: 0.9rem; +} + +/* ============================================================ + FOLDER GROUPS IN SIDEBAR + ============================================================ */ +.archive-actions { + display: flex; + gap: 4px; +} + +.folder-group { + list-style: none; + padding: 0; + margin: 0; +} + +.folder-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 4px; + margin: 14px 0 6px; + cursor: pointer; + user-select: none; + font-family: var(--font-mono); + font-size: 0.7rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-soft); + border-bottom: 1px solid var(--rule); + transition: color 0.15s; +} +.folder-group:first-child .folder-header { margin-top: 4px; } + +.folder-header:hover { color: var(--accent); } + +.folder-header-left { + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; +} + +.folder-caret { + display: inline-block; + width: 8px; + transition: transform 0.2s; + color: var(--ink-muted); +} +.folder-group.collapsed .folder-caret { transform: rotate(-90deg); } + +.folder-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.folder-count { + color: var(--ink-muted); + font-size: 0.65rem; + margin-left: 4px; +} + +.folder-menu-btn { + background: none; + border: none; + color: var(--ink-muted); + cursor: pointer; + padding: 2px 6px; + font-size: 0.9rem; + line-height: 1; + opacity: 0; + transition: opacity 0.15s, color 0.15s; +} +.folder-header:hover .folder-menu-btn { opacity: 1; } +.folder-menu-btn:hover { color: var(--accent); } + +.folder-items { + display: flex; + flex-direction: column; + gap: 8px; + overflow: hidden; + transition: max-height 0.25s ease-out; + max-height: 2000px; +} +.folder-group.collapsed .folder-items { + max-height: 0; +} + +.folder-empty { + font-style: italic; + font-size: 0.8rem; + color: var(--ink-muted); + padding: 6px 4px; +} + +/* Context menu for folder/PST actions */ +.context-menu { + position: fixed; + background: var(--paper); + border: 1px solid var(--ink); + box-shadow: 4px 4px 0 var(--rule); + z-index: 500; + min-width: 160px; + padding: 4px 0; +} +.context-menu button { + display: block; + width: 100%; + text-align: left; + background: none; + border: none; + padding: 8px 14px; + font-family: var(--font-display); + font-size: 0.9rem; + color: var(--ink); + cursor: pointer; +} +.context-menu button:hover { + background: var(--paper-deep); + color: var(--accent); +} +.context-menu button.danger:hover { + color: var(--accent); +} +.context-menu hr { + border: none; + border-top: 1px solid var(--rule-soft); + margin: 4px 0; +} + +/* ============================================================ + MODAL + ============================================================ */ +.modal { + position: fixed; + inset: 0; + background: color-mix(in srgb, var(--ink) 40%, transparent); + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + z-index: 200; + animation: fadeIn 0.2s ease-out; +} + +.modal-card { + background: var(--paper); + border: 1px solid var(--ink); + padding: 36px 36px 30px; + max-width: 420px; + width: 100%; + box-shadow: 8px 8px 0 var(--accent); + position: relative; +} +.modal-card h2 { + margin: 0 0 4px; + font-size: 1.5rem; + font-weight: 500; + letter-spacing: -0.015em; +} +.modal-subtitle { + margin: 0 0 24px; + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--ink-muted); + letter-spacing: 0.06em; +} +.modal-close { + position: absolute; + top: 10px; + right: 14px; + background: none; + border: none; + font-size: 1.8rem; + line-height: 1; + cursor: pointer; + color: var(--ink-muted); + transition: color 0.15s, transform 0.15s; +} +.modal-close:hover { + color: var(--accent); + transform: rotate(90deg); +} + +.auth-label select { + font-family: var(--font-display); + font-size: 1rem; + padding: 10px 12px; + border: 1px solid var(--ink); + background: var(--paper-deep); + color: var(--ink); + outline: none; + cursor: pointer; +} +.auth-label select:focus { + background: var(--paper); + box-shadow: 3px 3px 0 var(--accent); +} + +/* ============================================================ + READER + ============================================================ */ +.reader { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; +} + +.reader-head { + padding: 28px 40px 20px; + border-bottom: 1px solid var(--rule); + background: + linear-gradient(180deg, rgba(232, 225, 211, 0.3), transparent); +} + +.reader-title h2 { + margin: 0; + font-size: 2rem; + font-weight: 500; + letter-spacing: -0.02em; + line-height: 1.1; +} + +.reader-meta { + margin: 6px 0 20px; + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-muted); +} + +.search-wrap { + display: grid; + grid-template-columns: 1fr 200px; + gap: 10px; +} + +#searchInput, #folderFilter { + font-family: var(--font-display); + font-size: 1rem; + padding: 10px 14px; + border: 1px solid var(--ink); + background: var(--paper); + color: var(--ink); + outline: none; + transition: box-shadow 0.15s; +} +#searchInput:focus, #folderFilter:focus { + box-shadow: 3px 3px 0 var(--accent); +} +#searchInput:disabled, #folderFilter:disabled { + border-color: var(--rule); + color: var(--ink-muted); + background: var(--paper-deep); + cursor: not-allowed; +} +#searchInput::placeholder { + color: var(--ink-muted); + font-style: italic; +} +#folderFilter { cursor: pointer; font-family: var(--font-mono); font-size: 0.8rem; } + +.reader-body { + flex: 1; + overflow-y: auto; + padding: 20px 40px; + position: relative; +} + +.reader-placeholder { + text-align: center; + padding: 80px 20px; + color: var(--ink-muted); +} +.placeholder-ornament { + font-size: 2rem; + color: var(--accent); + margin-bottom: 16px; +} +.reader-placeholder p { + font-style: italic; + font-size: 1.1rem; + margin: 0; +} + +/* ============================================================ + MESSAGE LIST + ============================================================ */ +.message-list { + display: flex; + flex-direction: column; +} + +.message-row { + padding: 16px 0; + border-bottom: 1px solid var(--rule-soft); + cursor: pointer; + transition: all 0.12s ease; + display: grid; + grid-template-columns: 180px 1fr 140px; + gap: 20px; + align-items: baseline; +} +.message-row:hover { + background: color-mix(in srgb, var(--paper-deep) 70%, transparent); + padding-left: 8px; + padding-right: 8px; +} + +.mr-sender { + font-weight: 500; + color: var(--ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.95rem; +} + +.mr-main { + min-width: 0; + overflow: hidden; +} +.mr-subject { + font-size: 1.05rem; + font-weight: 500; + margin: 0 0 4px; + color: var(--ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mr-snippet { + font-size: 0.85rem; + color: var(--ink-muted); + line-height: 1.5; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; +} +.mr-snippet mark { + background: var(--highlight); + color: var(--ink); + padding: 0 2px; + font-weight: 500; +} + +.mr-meta { + text-align: right; + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--ink-muted); + letter-spacing: 0.04em; +} +.mr-folder { + margin-top: 4px; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--accent); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mr-attached { + display: inline-block; + margin-left: 4px; + color: var(--accent); +} + +/* ============================================================ + MESSAGE VIEW + ============================================================ */ +.message-view { + position: absolute; + inset: 20px 40px; + background: var(--paper); + border: 1px solid var(--ink); + box-shadow: 8px 8px 0 var(--rule); + overflow-y: auto; + padding: 40px 50px; + animation: slideIn 0.25s ease-out; +} + +@keyframes slideIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.close-btn { + position: absolute; + top: 14px; + right: 18px; + background: none; + border: none; + font-size: 2rem; + line-height: 1; + cursor: pointer; + color: var(--ink-muted); + transition: color 0.15s, transform 0.15s; +} +.close-btn:hover { + color: var(--accent); + transform: rotate(90deg); +} + +.message-header { + border-bottom: 2px solid var(--ink); + padding-bottom: 20px; + margin-bottom: 24px; +} + +.message-subject { + font-size: 1.75rem; + font-weight: 500; + line-height: 1.2; + letter-spacing: -0.015em; + margin-bottom: 16px; +} + +.message-meta { + display: grid; + grid-template-columns: 80px 1fr; + gap: 4px 20px; + margin: 0; +} +.message-meta dt { + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--ink-muted); + padding-top: 2px; +} +.message-meta dd { + margin: 0; + font-size: 0.95rem; + color: var(--ink-soft); + word-break: break-word; +} + +.message-body { + font-family: var(--font-display); + font-size: 1rem; + line-height: 1.7; + color: var(--ink); + white-space: pre-wrap; + word-wrap: break-word; + max-width: 70ch; +} + +/* ============================================================ + PAGINATOR + ============================================================ */ +.paginator { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 40px; + border-top: 1px solid var(--rule); + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--ink-soft); + background: var(--paper-deep); +} +.paginator button { + background: none; + border: 1px solid var(--ink); + padding: 6px 14px; + cursor: pointer; + font-family: inherit; + font-size: inherit; + color: var(--ink); + transition: all 0.15s; +} +.paginator button:hover:not(:disabled) { + background: var(--ink); + color: var(--paper); +} +.paginator button:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +/* ============================================================ + TOAST + ============================================================ */ +.toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: var(--ink); + color: var(--paper); + padding: 12px 20px; + font-family: var(--font-mono); + font-size: 0.8rem; + letter-spacing: 0.06em; + border-left: 3px solid var(--accent); + box-shadow: var(--shadow); + z-index: 1000; + animation: toastSlide 0.3s ease-out; +} +@keyframes toastSlide { + from { opacity: 0; transform: translate(-50%, 20px); } + to { opacity: 1; transform: translate(-50%, 0); } +} + +.hidden { display: none !important; } + +/* ============================================================ + AUTH SCREENS (login + setup) + ============================================================ */ +.auth-screen { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: + radial-gradient(circle at 30% 20%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 45%), + radial-gradient(circle at 70% 80%, color-mix(in srgb, var(--ink) 10%, transparent), transparent 55%), + var(--paper); + z-index: 100; + animation: fadeIn 0.3s ease-out; +} +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.auth-card { + width: 100%; + max-width: 440px; + background: var(--paper); + border: 1px solid var(--ink); + padding: 44px 40px 36px; + box-shadow: 8px 8px 0 var(--rule); + position: relative; +} +.auth-card::before { + content: ""; + position: absolute; + top: -6px; + left: -6px; + right: 6px; + bottom: 6px; + border: 1px solid var(--ink); + pointer-events: none; + z-index: -1; + background: var(--paper-deep); +} + +.auth-brand { + text-align: center; + margin-bottom: 28px; + padding-bottom: 20px; + border-bottom: 1px solid var(--rule); +} +.auth-brand svg { color: var(--ink); margin: 0 auto 12px; display: block; } +.auth-brand h1 { + margin: 0; + font-size: 2rem; + font-weight: 500; + letter-spacing: -0.02em; + line-height: 1; +} +.auth-tagline { + margin: 8px 0 0; + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--ink-muted); +} + +.auth-form { display: flex; flex-direction: column; gap: 16px; } + +.auth-intro { + margin: 0 0 6px; + font-size: 0.92rem; + color: var(--ink-soft); + line-height: 1.5; + font-style: italic; +} + +.auth-label { + display: flex; + flex-direction: column; + gap: 6px; +} +.auth-label > span { + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--ink-soft); +} +.auth-label small { + font-family: var(--font-mono); + font-size: 0.66rem; + color: var(--ink-muted); + letter-spacing: 0.04em; +} +.auth-label input { + font-family: var(--font-display); + font-size: 1rem; + padding: 10px 12px; + border: 1px solid var(--ink); + background: var(--paper-deep); + color: var(--ink); + outline: none; + transition: box-shadow 0.15s, background 0.15s; +} +.auth-label input:focus { + background: var(--paper); + box-shadow: 3px 3px 0 var(--accent); +} + +.auth-btn { + margin-top: 8px; + padding: 12px 18px; + font-family: var(--font-display); + font-size: 1.05rem; + font-weight: 500; + letter-spacing: -0.01em; + background: var(--ink); + color: var(--paper); + border: 1px solid var(--ink); + cursor: pointer; + transition: all 0.15s; +} +.auth-btn:hover { + background: var(--accent); + border-color: var(--accent); + transform: translate(-1px, -1px); + box-shadow: 3px 3px 0 var(--ink); +} + +.auth-error { + padding: 10px 14px; + background: color-mix(in srgb, var(--accent) 10%, transparent); + border-left: 3px solid var(--accent); + color: var(--accent); + font-size: 0.88rem; + font-family: var(--font-mono); + letter-spacing: 0.02em; +} + +/* ============================================================ + USER CHIP in masthead + ============================================================ */ +.user-chip { + font-family: var(--font-mono); + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-soft); + padding: 4px 10px; + border: 1px solid var(--rule); + background: var(--paper-deep); + margin-left: 12px; +} + +.logout-btn { + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-soft); + background: transparent; + border: 1px solid var(--rule); + padding: 5px 10px; + cursor: pointer; + transition: all 0.15s; + margin-left: 8px; +} +.logout-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +/* ADMINISTRATION button — shown next to the username for admin users only */ +.admin-nav-btn { + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--paper); + background: var(--accent); + border: 1px solid var(--accent); + padding: 5px 12px; + cursor: pointer; + transition: all 0.15s; + margin-left: 8px; + font-weight: 500; +} +.admin-nav-btn:hover { + background: var(--accent-soft); + border-color: var(--accent-soft); + transform: translateY(-1px); +} + +/* ============================================================ + SITE FOOTER (shown on the app view, not on login/setup) + ============================================================ */ +#mainApp:not(.hidden) { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.site-footer { + flex-shrink: 0; + padding: 14px 40px; + border-top: 1px solid var(--ink); + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-muted); + text-align: center; + background: var(--paper-deep); +} +@media (max-width: 900px) { + .site-footer { padding: 12px 20px; font-size: 0.62rem; } +} + +/* ============================================================ + THEME TOGGLE + ============================================================ */ +.theme-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--rule); + background: var(--paper-deep); + color: var(--ink-soft); + cursor: pointer; + padding: 0; + transition: all 0.2s ease; + position: relative; + overflow: hidden; +} +.theme-toggle:hover { + border-color: var(--accent); + color: var(--accent); + transform: translateY(-1px); +} +.theme-toggle svg { + width: 16px; + height: 16px; + transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1); +} +.theme-toggle:hover svg { + transform: rotate(25deg); +} +:root[data-theme="dark"] .theme-toggle:hover svg { + transform: rotate(-25deg); +} + +/* Icon visibility — show sun in light mode, moon in dark mode */ +.theme-toggle .icon-sun { display: inline-block; } +.theme-toggle .icon-moon { display: none; } +:root[data-theme="dark"] .theme-toggle .icon-sun { display: none; } +:root[data-theme="dark"] .theme-toggle .icon-moon { display: inline-block; } + +/* Floating variant shown on auth/setup screens */ +.theme-toggle-float { + position: fixed; + top: 20px; + right: 20px; + z-index: 200; +} +/* Hide the floating toggle once the main app is visible (in-masthead toggle takes over) */ +body:has(#mainApp:not(.hidden)) .theme-toggle-float { display: none; } + +/* ============================================================ + SCROLLBARS + ============================================================ */ +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: var(--rule); + border: 2px solid var(--paper); +} +::-webkit-scrollbar-thumb:hover { background: var(--ink-muted); } + +/* ============================================================ + RESPONSIVE + ============================================================ */ +@media (max-width: 900px) { + .workspace { grid-template-columns: 1fr; } + .sidebar { border-right: none; border-bottom: 1px solid var(--rule); max-height: 50vh; } + .masthead { padding: 20px 24px 14px; flex-wrap: wrap; gap: 16px; } + .reader-head, .reader-body, .paginator { padding-left: 24px; padding-right: 24px; } + .search-wrap { grid-template-columns: 1fr; } + .message-row { grid-template-columns: 1fr; gap: 6px; } + .mr-meta { text-align: left; } + .message-view { inset: 12px; padding: 24px 20px; } + .message-meta { grid-template-columns: 60px 1fr; } +}