This commit is contained in:
jpmvaz
2026-09-13 20:07:03 +01:00
commit 5482731e78
37 changed files with 15639 additions and 0 deletions
@@ -0,0 +1,7 @@
data/
**/__pycache__
**/*.pyc
.git
.gitignore
*.md
.DS_Store
+41
View File
@@ -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"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,940 @@
/* ============================================================
ADMIN PANEL — darker, more clinical than the main app
Supports light (default) and dark themes via [data-theme]
============================================================ */
:root,
:root[data-theme="light"] {
--paper: #eae3d3;
--paper-deep: #ddd5c2;
--ink: #141210;
--ink-soft: #2d2822;
--ink-muted: #5e564b;
--rule: #bcb09c;
--rule-soft: #d2c7b3;
--accent: #8a2a1a;
--accent-soft: #b8553f;
--highlight: #d6c26a;
--ok: #2a6635;
--warn: #965f1a;
--diagonal-stripe: rgba(20, 18, 16, 0.02);
--grain-opacity: 0.3;
--modal-backdrop: rgba(20, 18, 16, 0.5);
--font-display: "Fraunces", Georgia, serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
}
:root[data-theme="dark"] {
--paper: #13110e;
--paper-deep: #1c1913;
--ink: #e4dac2;
--ink-soft: #c0b49a;
--ink-muted: #807663;
--rule: #342d22;
--rule-soft: #26211a;
--accent: #c65840;
--accent-soft: #df7f63;
--highlight: #c9ab47;
--ok: #65a572;
--warn: #c68c43;
--diagonal-stripe: rgba(228, 218, 194, 0.025);
--grain-opacity: 0.1;
--modal-backdrop: rgba(0, 0, 0, 0.75);
}
* { 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;
}
body {
display: flex;
flex-direction: column;
min-height: 100vh;
background-image:
repeating-linear-gradient(45deg, transparent 0, transparent 30px, var(--diagonal-stripe) 30px, var(--diagonal-stripe) 31px);
}
.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,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0.1 0 0 0 0 0.08 0 0 0 0 0.06 0 0 0 0.35 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
}
:root[data-theme="dark"] .grain {
mix-blend-mode: screen;
}
.hidden { display: none !important; }
/* ============================================================
AUTH SCREENS
============================================================ */
.auth-screen {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 100;
}
.auth-card {
width: 100%;
max-width: 420px;
background: var(--paper);
border: 1px solid var(--ink);
padding: 44px 40px 36px;
box-shadow: 10px 10px 0 var(--accent);
position: relative;
}
.auth-brand {
text-align: center;
margin-bottom: 28px;
padding-bottom: 20px;
border-bottom: 1px solid var(--rule);
}
.auth-brand h1 {
margin: 0;
font-size: 1.9rem;
font-weight: 500;
letter-spacing: -0.02em;
}
.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);
}
.admin-badge {
display: inline-block;
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.22em;
font-weight: 700;
color: var(--paper);
background: var(--accent);
padding: 4px 10px;
margin-bottom: 14px;
}
.admin-badge.small {
margin-bottom: 0;
margin-right: 14px;
font-size: 0.6rem;
padding: 3px 8px;
}
.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);
}
.auth-label input,
.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;
}
.auth-label input:focus,
.auth-label select: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: 1rem;
font-weight: 500;
background: var(--ink);
color: var(--paper);
border: 1px solid var(--ink);
cursor: pointer;
text-decoration: none;
text-align: center;
display: block;
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) 12%, transparent);
border-left: 3px solid var(--accent);
color: var(--accent);
font-size: 0.88rem;
font-family: var(--font-mono);
}
/* ============================================================
MASTHEAD
============================================================ */
.masthead {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 40px;
border-bottom: 2px solid var(--ink);
background: var(--paper-deep);
}
.masthead-left {
display: flex;
align-items: center;
}
.masthead-title h1 {
margin: 0;
font-size: 1.6rem;
font-weight: 500;
letter-spacing: -0.02em;
line-height: 1;
}
.tagline {
margin: 4px 0 0;
font-family: var(--font-mono);
font-size: 0.66rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--ink-muted);
}
.masthead-right {
display: flex;
align-items: center;
gap: 10px;
}
.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);
}
.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;
}
.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;
}
.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); }
.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;
}
body:has(#adminApp:not(.hidden)) .theme-toggle-float { display: none; }
/* ============================================================
MAIN
============================================================ */
.admin-main {
padding: 36px 40px;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
/* Tab navigation */
.admin-tabs {
display: flex;
gap: 0;
margin-bottom: 28px;
border-bottom: 2px solid var(--ink);
}
.admin-tab {
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.16em;
text-transform: uppercase;
font-weight: 500;
background: transparent;
color: var(--ink-muted);
border: 1px solid transparent;
border-bottom: none;
padding: 10px 22px;
cursor: pointer;
margin-bottom: -2px;
transition: all 0.15s;
position: relative;
}
.admin-tab:hover {
color: var(--ink-soft);
}
.admin-tab.active {
background: var(--paper);
border-color: var(--ink);
color: var(--ink);
}
.admin-tab.active::after {
content: "";
position: absolute;
bottom: -2px;
left: -1px;
right: -1px;
height: 4px;
background: var(--paper);
}
.admin-panel { animation: fadeIn 0.2s ease-out; }
.section-heading {
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-soft);
margin: 0;
}
.admin-toolbar {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--rule);
}
.toolbar-meta {
margin: 6px 0 0;
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--ink-muted);
letter-spacing: 0.04em;
}
.primary-btn {
padding: 10px 18px;
font-family: var(--font-display);
font-size: 0.95rem;
font-weight: 500;
background: var(--ink);
color: var(--paper);
border: 1px solid var(--ink);
cursor: pointer;
transition: all 0.15s;
}
.primary-btn:hover {
background: var(--accent);
border-color: var(--accent);
transform: translate(-1px, -1px);
box-shadow: 3px 3px 0 var(--ink);
}
/* ============================================================
USER TABLE
============================================================ */
.user-list-wrap {
background: var(--paper);
border: 1px solid var(--rule);
overflow-x: auto;
}
.user-table {
width: 100%;
border-collapse: collapse;
font-size: 0.92rem;
}
.user-table thead {
background: var(--paper-deep);
border-bottom: 2px solid var(--ink);
}
.user-table th {
text-align: left;
padding: 12px 16px;
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-soft);
font-weight: 500;
}
.user-table td {
padding: 14px 16px;
border-bottom: 1px solid var(--rule-soft);
vertical-align: middle;
}
.user-table tr:last-child td { border-bottom: none; }
.user-table tr:hover td { background: color-mix(in srgb, var(--paper-deep) 70%, transparent); }
.username-cell {
font-weight: 500;
font-size: 1rem;
}
.username-cell .me-tag {
display: inline-block;
margin-left: 6px;
font-family: var(--font-mono);
font-size: 0.6rem;
letter-spacing: 0.1em;
background: var(--accent);
color: var(--paper);
padding: 2px 6px;
}
.role-pill {
display: inline-block;
padding: 3px 10px;
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
border: 1px solid var(--rule);
background: var(--paper-deep);
cursor: pointer;
transition: all 0.15s;
}
.role-pill.admin {
background: var(--ink);
color: var(--paper);
border-color: var(--ink);
}
.role-pill:hover { border-color: var(--accent); }
.mfa-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--ink-muted);
}
.mfa-indicator.on { color: var(--ok); }
.mfa-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ink-muted);
}
.mfa-indicator.on .mfa-dot { background: var(--ok); box-shadow: 0 0 0 3px rgba(42, 102, 53, 0.18); }
.date-cell {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--ink-muted);
}
.row-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.row-actions button {
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 5px 10px;
background: transparent;
border: 1px solid var(--rule);
color: var(--ink-soft);
cursor: pointer;
transition: all 0.15s;
}
.row-actions button:hover {
border-color: var(--ink);
color: var(--ink);
}
.row-actions button.danger:hover {
border-color: var(--accent);
color: var(--accent);
}
.loading-row, .empty-row {
text-align: center;
padding: 40px;
color: var(--ink-muted);
font-style: italic;
}
/* File table specifics */
.filename-cell {
font-family: var(--font-mono);
font-size: 0.85rem;
word-break: break-all;
max-width: 280px;
}
.folder-cell {
font-size: 0.9rem;
color: var(--ink-soft);
}
.folder-cell.uncategorized { color: var(--ink-muted); font-style: italic; }
.status-pill {
display: inline-block;
padding: 2px 10px;
font-family: var(--font-mono);
font-size: 0.65rem;
letter-spacing: 0.12em;
text-transform: uppercase;
border: 1px solid currentColor;
}
.status-pill.ready { color: var(--ok); }
.status-pill.indexing { color: var(--warn); }
.status-pill.failed { color: var(--accent); }
.size-cell, .num-cell {
font-family: var(--font-mono);
font-size: 0.82rem;
color: var(--ink-soft);
text-align: right;
}
/* ============================================================
PERMISSIONS TAB
============================================================ */
.view-switch {
display: inline-flex;
border: 1px solid var(--rule);
background: var(--paper-deep);
}
.view-btn {
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.14em;
text-transform: uppercase;
background: transparent;
color: var(--ink-muted);
border: none;
padding: 7px 16px;
cursor: pointer;
transition: all 0.15s;
}
.view-btn:hover { color: var(--ink); }
.view-btn.active {
background: var(--ink);
color: var(--paper);
}
.perm-hint {
font-size: 0.88rem;
font-style: italic;
color: var(--ink-muted);
margin: 0 0 18px;
padding: 12px 16px;
background: var(--paper-deep);
border-left: 3px solid var(--accent-soft);
line-height: 1.5;
}
.perm-matrix-wrap {
background: var(--paper);
border: 1px solid var(--rule);
overflow-x: auto;
max-height: 70vh;
}
.perm-matrix {
border-collapse: separate;
border-spacing: 0;
min-width: 100%;
}
.perm-matrix thead th {
position: sticky;
top: 0;
z-index: 2;
background: var(--paper-deep);
border-bottom: 2px solid var(--ink);
}
.perm-matrix th.rowhead,
.perm-matrix td.rowhead {
position: sticky;
left: 0;
z-index: 1;
background: var(--paper-deep);
border-right: 1px solid var(--rule);
min-width: 180px;
max-width: 260px;
}
.perm-matrix thead th.rowhead {
z-index: 3; /* top-left corner sits above both sticky axes */
background: var(--paper-deep);
}
.perm-matrix .col-header {
writing-mode: vertical-rl;
transform: rotate(180deg);
white-space: nowrap;
padding: 14px 8px;
font-family: var(--font-display);
font-weight: 500;
font-size: 0.92rem;
text-transform: none;
letter-spacing: 0;
color: var(--ink);
max-height: 220px;
height: 220px;
vertical-align: bottom;
}
.perm-matrix .rowlabel {
font-family: var(--font-display);
font-weight: 500;
font-size: 0.95rem;
padding: 10px 14px;
color: var(--ink);
}
.perm-matrix .rowlabel .role-tag {
display: inline-block;
margin-left: 6px;
font-family: var(--font-mono);
font-size: 0.6rem;
letter-spacing: 0.14em;
text-transform: uppercase;
padding: 2px 6px;
background: var(--ink);
color: var(--paper);
vertical-align: middle;
}
.perm-matrix .rowlabel.admin-row {
color: var(--ink-muted);
font-style: italic;
}
.perm-matrix .cell {
text-align: center;
padding: 0;
border-bottom: 1px solid var(--rule-soft);
border-right: 1px solid var(--rule-soft);
width: 52px;
height: 44px;
}
.perm-matrix .cell label {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
cursor: pointer;
transition: background 0.12s;
}
.perm-matrix .cell label:hover {
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.perm-matrix .cell input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--accent);
}
.perm-matrix .cell input[type="checkbox"]:disabled {
cursor: not-allowed;
}
.perm-matrix .cell.implicit {
background: color-mix(in srgb, var(--ink) 6%, transparent);
}
.perm-matrix .cell.implicit::after {
content: "✓";
color: var(--ink-muted);
font-family: var(--font-mono);
font-size: 1rem;
}
.perm-empty {
text-align: center;
padding: 60px 20px;
color: var(--ink-muted);
font-style: italic;
}
/* ============================================================
MODAL
============================================================ */
.modal {
position: fixed;
inset: 0;
background: var(--modal-backdrop);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 200;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.modal-card {
background: var(--paper);
border: 1px solid var(--ink);
padding: 36px 36px 30px;
max-width: 440px;
width: 100%;
box-shadow: 8px 8px 0 var(--accent);
position: relative;
max-height: 90vh;
overflow-y: auto;
}
.modal-card.wide { max-width: 720px; }
.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);
}
/* ============================================================
MFA PANEL
============================================================ */
.mfa-panel {
padding: 20px;
background: var(--paper-deep);
border: 1px solid var(--rule);
margin-bottom: 16px;
}
.mfa-status {
font-family: var(--font-mono);
font-size: 0.8rem;
margin-bottom: 16px;
padding-bottom: 14px;
border-bottom: 1px solid var(--rule);
color: var(--ink-soft);
}
.mfa-status.enabled { color: var(--ok); }
.mfa-status .badge {
display: inline-block;
padding: 3px 8px;
letter-spacing: 0.1em;
font-weight: 500;
text-transform: uppercase;
font-size: 0.65rem;
margin-right: 8px;
}
.mfa-status.enabled .badge {
background: var(--ok);
color: var(--paper);
}
.mfa-status .badge.off {
background: var(--ink-muted);
color: var(--paper);
}
.mfa-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.mfa-actions button {
font-family: var(--font-display);
font-size: 0.92rem;
padding: 8px 16px;
background: var(--paper);
color: var(--ink);
border: 1px solid var(--ink);
cursor: pointer;
transition: all 0.15s;
}
.mfa-actions button.primary {
background: var(--ink);
color: var(--paper);
}
.mfa-actions button:hover {
background: var(--accent);
color: var(--paper);
border-color: var(--accent);
}
.mfa-enroll {
padding: 20px;
background: var(--paper-deep);
border: 1px solid var(--rule);
}
.enroll-grid {
display: grid;
grid-template-columns: 200px 1fr;
gap: 28px;
align-items: start;
}
.enroll-qr {
background: white;
padding: 10px;
border: 1px solid var(--rule);
display: flex;
align-items: center;
justify-content: center;
}
.enroll-qr img {
width: 100%;
height: auto;
display: block;
}
.enroll-step {
margin: 0 0 10px;
font-size: 0.9rem;
color: var(--ink-soft);
line-height: 1.5;
}
.secret-box {
display: block;
font-family: var(--font-mono);
font-size: 0.9rem;
letter-spacing: 0.08em;
background: var(--ink);
color: var(--highlight);
padding: 10px 14px;
margin: 0 0 16px;
word-break: break-all;
user-select: all;
}
.confirm-form { margin-top: 4px; }
@media (max-width: 600px) {
.enroll-grid { grid-template-columns: 1fr; }
.enroll-qr { max-width: 240px; margin: 0 auto; }
.admin-main { padding: 20px; }
.masthead { padding: 14px 20px; }
.user-table th:nth-child(4),
.user-table td:nth-child(4) { display: none; }
}
/* ============================================================
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);
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); }
}
/* 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); }
@@ -0,0 +1,866 @@
/* ============================================================
PST Archive — Admin panel
- Login (with MFA)
- User CRUD: list, create, delete, change role, reset password
- MFA: begin enrollment, scan QR, confirm 6-digit code, disable
============================================================ */
const API = "/api";
const state = {
user: null,
users: [],
folders: [],
files: [],
mfaTarget: null,
folderEdit: null, // {id, name} if renaming, null if creating
activeTab: "users",
permissions: { users: [], folders: [], grants: [] },
permView: "by-user", // "by-user" or "by-folder"
};
const $ = (id) => document.getElementById(id);
// ---------- helpers ----------
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 fmtBytes(n) {
if (!n && n !== 0) return "—";
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 fmtDateTime(iso) {
if (!iso) return "never";
try {
const d = new Date(iso);
if (isNaN(d)) return iso;
return d.toLocaleString("en-US", { 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
// ---------- 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;
}
// ---------- screens ----------
function showScreen(which) {
["setupNotice", "loginScreen", "adminApp"].forEach((id) => {
$(id).classList.toggle("hidden", id !== which);
});
}
// ---------- bootstrap ----------
async function bootstrap() {
try {
const { user } = await api("/me");
state.user = user;
enterAdmin();
return;
} catch (e) {
if (e.status !== 401) { toast("Connection problem"); }
}
try {
const { needs_setup } = await api("/setup/status");
if (needs_setup) {
$("gotoMainBtn").href = window.location.protocol + "//" + window.location.hostname + ":8000/";
showScreen("setupNotice");
} else {
showScreen("loginScreen");
setTimeout(() => $("loginForm").username?.focus(), 50);
}
} catch {
showScreen("loginScreen");
}
}
// ---------- login ----------
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 mfa = form.totp_code.value.trim();
if (mfa) payload.totp_code = mfa;
try {
const { user } = await api("/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
state.user = user;
enterAdmin();
} catch (err) {
if (err.status === 401 && err.body && err.body.mfa_required) {
$("mfaField").classList.remove("hidden");
setTimeout(() => form.totp_code.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;
showScreen("loginScreen");
$("loginForm").reset();
$("mfaField").classList.add("hidden");
$("loginError").classList.add("hidden");
}
function enterAdmin() {
showScreen("adminApp");
$("userChip").textContent = `${state.user.username} · admin`;
loadUsers();
}
// ---------- users ----------
async function loadUsers() {
try {
const users = await api("/users");
state.users = users;
renderUsers();
} catch (e) {
if (e.status === 401) { handleLogout(); return; }
toast(`Failed to load users: ${e.message}`);
}
}
function renderUsers() {
const tbody = $("userTableBody");
$("userCount").textContent =
`${state.users.length} user${state.users.length === 1 ? "" : "s"} · ` +
`${state.users.filter(u => u.role === "admin").length} admin${state.users.filter(u => u.role === "admin").length === 1 ? "" : "s"}`;
if (!state.users.length) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-row">No users yet.</td></tr>';
return;
}
tbody.innerHTML = state.users.map((u) => {
const isMe = u.id === state.user.id;
return `
<tr data-id="${u.id}">
<td class="username-cell">
${escapeHtml(u.username)}
${isMe ? '<span class="me-tag">you</span>' : ""}
</td>
<td>
<span class="role-pill ${u.role}" data-action="toggle-role" data-id="${u.id}" title="Click to change role">${escapeHtml(u.role)}</span>
</td>
<td>
<span class="mfa-indicator ${u.mfa_enabled ? 'on' : ''}">
<span class="mfa-dot"></span>${u.mfa_enabled ? "enabled" : "disabled"}
</span>
</td>
<td class="date-cell">${fmtDate(u.created_at)}</td>
<td class="date-cell">${fmtDateTime(u.last_login)}</td>
<td>
<div class="row-actions">
<button data-action="mfa" data-id="${u.id}">MFA</button>
<button data-action="password" data-id="${u.id}">Password</button>
<button data-action="delete" data-id="${u.id}" class="danger" ${isMe ? "disabled" : ""}>Delete</button>
</div>
</td>
</tr>`;
}).join("");
tbody.querySelectorAll("[data-action]").forEach((el) => {
el.addEventListener("click", handleAction);
});
}
async function handleAction(e) {
const action = e.currentTarget.dataset.action;
const id = e.currentTarget.dataset.id;
const user = state.users.find((u) => u.id === id);
if (!user) return;
switch (action) {
case "toggle-role":
await toggleRole(user);
break;
case "password":
openResetPassword(user);
break;
case "mfa":
openMfaModal(user);
break;
case "delete":
await deleteUser(user);
break;
}
}
async function toggleRole(user) {
const newRole = user.role === "admin" ? "user" : "admin";
if (!confirm(`Change ${user.username}'s role from "${user.role}" to "${newRole}"?`)) return;
try {
await api(`/users/${user.id}/role`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role: newRole }),
});
toast(`${user.username} is now ${newRole}`);
loadUsers();
} catch (err) {
toast(`Role change failed: ${err.message}`);
}
}
async function deleteUser(user) {
if (!confirm(`Permanently delete user "${user.username}"?\n\nThis cannot be undone.`)) return;
try {
await api(`/users/${user.id}`, { method: "DELETE" });
toast(`${user.username} deleted`);
loadUsers();
} catch (err) {
toast(`Delete failed: ${err.message}`);
}
}
// ---------- create user modal ----------
function openCreateUser() {
$("createUserForm").reset();
$("createError").classList.add("hidden");
$("createUserModal").classList.remove("hidden");
setTimeout(() => $("createUserForm").username.focus(), 50);
}
async function handleCreateUser(e) {
e.preventDefault();
const form = e.target;
const errEl = $("createError");
errEl.classList.add("hidden");
const payload = {
username: form.username.value.trim(),
password: form.password.value,
role: form.role.value,
};
try {
await api("/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
toast(`User "${payload.username}" created`);
closeModals();
loadUsers();
} catch (err) {
errEl.textContent = err.message || "Create failed.";
errEl.classList.remove("hidden");
}
}
// ---------- reset password modal ----------
function openResetPassword(user) {
$("resetPasswordSubject").textContent = `Setting new password for ${user.username}`;
$("resetPasswordForm").reset();
$("resetError").classList.add("hidden");
$("resetPasswordForm").dataset.userId = user.id;
$("resetPasswordModal").classList.remove("hidden");
setTimeout(() => $("resetPasswordForm").password.focus(), 50);
}
async function handleResetPassword(e) {
e.preventDefault();
const form = e.target;
const errEl = $("resetError");
errEl.classList.add("hidden");
const userId = form.dataset.userId;
try {
await api(`/users/${userId}/password`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: form.password.value }),
});
toast("Password updated");
closeModals();
} catch (err) {
errEl.textContent = err.message || "Reset failed.";
errEl.classList.remove("hidden");
}
}
// ---------- MFA modal ----------
function openMfaModal(user) {
state.mfaTarget = { user_id: user.id, username: user.username, mfa_enabled: user.mfa_enabled };
$("mfaSubject").textContent = `${user.username}`;
$("mfaEnrollPanel").classList.add("hidden");
renderMfaPanel();
$("mfaModal").classList.remove("hidden");
}
function renderMfaPanel() {
const enabled = state.mfaTarget.mfa_enabled;
const statusEl = $("mfaCurrentStatus");
statusEl.className = "mfa-status" + (enabled ? " enabled" : "");
statusEl.innerHTML = enabled
? `<span class="badge">Enabled</span> This user is required to enter a TOTP code at login.`
: `<span class="badge off">Disabled</span> This user signs in with password only.`;
const actions = $("mfaActions");
actions.innerHTML = enabled
? `<button data-mfa-action="disable">Disable MFA</button>
<button data-mfa-action="reenroll" class="primary">Re-enroll</button>`
: `<button data-mfa-action="enroll" class="primary">Begin enrollment</button>`;
actions.querySelectorAll("[data-mfa-action]").forEach((b) => {
b.addEventListener("click", () => handleMfaAction(b.dataset.mfaAction));
});
}
async function handleMfaAction(action) {
const userId = state.mfaTarget.user_id;
if (action === "disable") {
if (!confirm(`Disable MFA for ${state.mfaTarget.username}?`)) return;
try {
await api(`/users/${userId}/mfa/disable`, { method: "POST" });
toast("MFA disabled");
state.mfaTarget.mfa_enabled = false;
renderMfaPanel();
loadUsers();
} catch (err) {
toast(`Disable failed: ${err.message}`);
}
return;
}
// enroll or re-enroll — generate a fresh secret
try {
const res = await api(`/users/${userId}/mfa/begin`, { method: "POST" });
$("mfaSecretText").textContent = res.secret;
// Add cache-busting param so the QR re-renders with the new secret
$("mfaQrImage").src = `${API}/users/${userId}/mfa/qr?t=${Date.now()}`;
$("mfaEnrollPanel").classList.remove("hidden");
$("mfaConfirmError").classList.add("hidden");
$("mfaConfirmForm").reset();
setTimeout(() => $("mfaConfirmForm").totp_code.focus(), 100);
} catch (err) {
toast(`Enrollment failed: ${err.message}`);
}
}
async function handleMfaConfirm(e) {
e.preventDefault();
const form = e.target;
const errEl = $("mfaConfirmError");
errEl.classList.add("hidden");
const userId = state.mfaTarget.user_id;
try {
await api(`/users/${userId}/mfa/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ totp_code: form.totp_code.value.trim() }),
});
toast(`MFA enabled for ${state.mfaTarget.username}`);
state.mfaTarget.mfa_enabled = true;
$("mfaEnrollPanel").classList.add("hidden");
renderMfaPanel();
loadUsers();
} catch (err) {
errEl.textContent = err.message || "Confirmation failed.";
errEl.classList.remove("hidden");
}
}
// ---------- modals ----------
function closeModals() {
document.querySelectorAll(".modal").forEach((m) => m.classList.add("hidden"));
}
// ---------- Theme ----------
function initTheme() {
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 {}
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 {}
}
initTheme();
// ---------- Tabs ----------
function switchTab(tab) {
state.activeTab = tab;
document.querySelectorAll(".admin-tab").forEach((el) => {
el.classList.toggle("active", el.dataset.tab === tab);
});
document.querySelectorAll(".admin-panel").forEach((el) => {
el.classList.toggle("hidden", el.dataset.panel !== tab);
});
if (tab === "users") loadUsers();
else if (tab === "folders") loadFolders();
else if (tab === "files") loadFiles();
else if (tab === "permissions") loadPermissions();
}
// ---------- Folder management ----------
async function loadFolders() {
try {
const folders = await api("/folders");
state.folders = folders;
renderFolders();
} catch (e) {
if (e.status === 401) { handleLogout(); return; }
toast(`Failed to load folders: ${e.message}`);
}
}
function renderFolders() {
const tbody = $("folderTableBody");
$("folderCount").textContent =
`${state.folders.length} folder${state.folders.length === 1 ? "" : "s"}`;
if (!state.folders.length) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-row">No folders yet. Click "+ New folder" to create one.</td></tr>';
return;
}
tbody.innerHTML = state.folders.map((f) => `
<tr data-id="${f.id}">
<td class="username-cell">${escapeHtml(f.name)}</td>
<td class="num-cell">${f.file_count}</td>
<td class="date-cell">${fmtDate(f.created_at)}</td>
<td>
<div class="row-actions">
<button data-folder-action="rename" data-id="${f.id}">Rename</button>
<button data-folder-action="delete" data-id="${f.id}" class="danger">Delete</button>
</div>
</td>
</tr>`).join("");
tbody.querySelectorAll("[data-folder-action]").forEach((el) => {
el.addEventListener("click", handleFolderAction);
});
}
function handleFolderAction(e) {
const action = e.currentTarget.dataset.folderAction;
const id = e.currentTarget.dataset.id;
const folder = state.folders.find((f) => f.id === id);
if (!folder) return;
if (action === "rename") openFolderModal(folder);
else if (action === "delete") deleteFolder(folder);
}
function openFolderModal(folder = null) {
state.folderEdit = folder;
$("folderForm").reset();
$("folderError").classList.add("hidden");
if (folder) {
$("folderModalTitle").textContent = "Rename folder";
$("folderSubmitBtn").textContent = "Save changes";
$("folderForm").name.value = folder.name;
} else {
$("folderModalTitle").textContent = "New folder";
$("folderSubmitBtn").textContent = "Create folder";
}
$("folderModal").classList.remove("hidden");
setTimeout(() => $("folderForm").name.focus(), 50);
}
async function handleFolderSubmit(e) {
e.preventDefault();
const form = e.target;
const errEl = $("folderError");
errEl.classList.add("hidden");
const name = form.name.value.trim();
try {
if (state.folderEdit) {
await api(`/folders/${state.folderEdit.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
toast("Folder renamed.");
} else {
await api("/folders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
toast("Folder created.");
}
closeModals();
loadFolders();
if (state.activeTab === "files") loadFiles();
if (state.activeTab === "permissions") loadPermissions();
} catch (err) {
errEl.textContent = err.message || "Save failed.";
errEl.classList.remove("hidden");
}
}
async function deleteFolder(folder) {
const msg = folder.file_count > 0
? `Delete folder "${folder.name}"?\n\n${folder.file_count} archive${folder.file_count === 1 ? "" : "s"} will be moved to Uncategorized.`
: `Delete folder "${folder.name}"?`;
if (!confirm(msg)) return;
try {
await api(`/folders/${folder.id}`, { method: "DELETE" });
toast("Folder deleted.");
loadFolders();
if (state.activeTab === "files") loadFiles();
if (state.activeTab === "permissions") loadPermissions();
} catch (err) {
toast(`Delete failed: ${err.message}`);
}
}
// ---------- File management ----------
async function loadFiles() {
try {
const files = await api("/pst-files");
state.files = files;
renderFiles();
} catch (e) {
if (e.status === 401) { handleLogout(); return; }
toast(`Failed to load files: ${e.message}`);
}
}
function renderFiles() {
const tbody = $("fileTableBody");
const total = state.files.length;
const totalSize = state.files.reduce((s, f) => s + (f.size_bytes || 0), 0);
$("fileCount").textContent = total
? `${total} file${total === 1 ? "" : "s"} · ${fmtBytes(totalSize)} total`
: "no files";
if (!total) {
tbody.innerHTML = '<tr><td colspan="7" class="empty-row">No PST files have been uploaded yet.</td></tr>';
return;
}
tbody.innerHTML = state.files.map((f) => `
<tr data-id="${f.id}">
<td class="filename-cell" title="${escapeHtml(f.original_name)}">${escapeHtml(f.original_name)}</td>
<td class="folder-cell ${f.folder_name ? "" : "uncategorized"}">${f.folder_name ? escapeHtml(f.folder_name) : "Uncategorized"}</td>
<td class="size-cell">${fmtBytes(f.size_bytes)}</td>
<td><span class="status-pill ${f.status}">${f.status}</span></td>
<td class="num-cell">${f.status === "ready" ? (f.message_count || 0).toLocaleString() : "—"}</td>
<td class="date-cell">${fmtDate(f.uploaded_at)}</td>
<td>
<div class="row-actions">
<button data-file-action="delete" data-id="${f.id}" class="danger">Delete</button>
</div>
</td>
</tr>`).join("");
tbody.querySelectorAll("[data-file-action]").forEach((el) => {
el.addEventListener("click", handleFileAction);
});
}
function handleFileAction(e) {
const action = e.currentTarget.dataset.fileAction;
const id = e.currentTarget.dataset.id;
const file = state.files.find((f) => f.id === id);
if (!file) return;
if (action === "delete") deleteFile(file);
}
async function deleteFile(file) {
if (!confirm(`Permanently delete "${file.original_name}"?\n\nThis removes the PST file and all ${(file.message_count || 0).toLocaleString()} indexed messages. Cannot be undone.`)) return;
try {
await api(`/pst-files/${file.id}`, { method: "DELETE" });
toast("File deleted.");
loadFiles();
if (state.activeTab === "folders") loadFolders(); // counts change
} catch (err) {
toast(`Delete failed: ${err.message}`);
}
}
// ---------- Permissions ----------
async function loadPermissions() {
try {
const data = await api("/permissions");
state.permissions = data;
renderPermissions();
} catch (e) {
if (e.status === 401) { handleLogout(); return; }
toast(`Failed to load permissions: ${e.message}`);
}
}
function hasGrant(userId, folderId) {
return state.permissions.grants.some(
(g) => g.user_id === userId && g.folder_id === folderId
);
}
function renderPermissions() {
const { users, folders } = state.permissions;
const nonAdmins = users.filter((u) => u.role !== "admin");
const adminCount = users.length - nonAdmins.length;
$("permissionsMeta").textContent =
`${users.length} user${users.length === 1 ? "" : "s"}` +
` (${adminCount} admin${adminCount === 1 ? "" : "s"}) · ` +
`${folders.length} folder${folders.length === 1 ? "" : "s"}`;
const thead = $("permMatrixHead");
const tbody = $("permMatrixBody");
if (!folders.length) {
thead.innerHTML = "";
tbody.innerHTML = `<tr><td class="perm-empty">
No folders yet. Create one in the Folders tab to start assigning access.
</td></tr>`;
return;
}
if (state.permView === "by-user") {
renderPermByUser(thead, tbody, users, folders);
} else {
renderPermByFolder(thead, tbody, users, folders, nonAdmins);
}
}
function renderPermByUser(thead, tbody, users, folders) {
// Rows = users, Columns = folders
thead.innerHTML = `
<tr>
<th class="rowhead">User</th>
${folders.map((f) => `<th class="col-header" title="${escapeHtml(f.name)}">${escapeHtml(f.name)}</th>`).join("")}
</tr>
`;
tbody.innerHTML = users.map((u) => {
const isAdmin = u.role === "admin";
const rowClass = isAdmin ? "admin-row" : "";
return `
<tr data-user-id="${u.id}">
<td class="rowhead rowlabel ${rowClass}">
${escapeHtml(u.username)}
${isAdmin ? '<span class="role-tag">admin</span>' : ""}
</td>
${folders.map((f) => {
if (isAdmin) {
return `<td class="cell implicit" title="Admins have access to all folders"></td>`;
}
const checked = hasGrant(u.id, f.id) ? "checked" : "";
return `
<td class="cell">
<label>
<input type="checkbox" data-perm-toggle
data-user="${u.id}" data-folder="${f.id}"
${checked} />
</label>
</td>
`;
}).join("")}
</tr>
`;
}).join("");
bindPermToggles();
}
function renderPermByFolder(thead, tbody, users, folders, nonAdmins) {
// Rows = folders, Columns = non-admin users (admins don't need toggles)
if (!nonAdmins.length) {
thead.innerHTML = "";
tbody.innerHTML = `<tr><td class="perm-empty">
No non-admin users exist yet. Create one in the Users tab to start assigning access.
</td></tr>`;
return;
}
thead.innerHTML = `
<tr>
<th class="rowhead">Folder</th>
${nonAdmins.map((u) => `<th class="col-header" title="${escapeHtml(u.username)}">${escapeHtml(u.username)}</th>`).join("")}
</tr>
`;
tbody.innerHTML = folders.map((f) => `
<tr data-folder-id="${f.id}">
<td class="rowhead rowlabel">${escapeHtml(f.name)}</td>
${nonAdmins.map((u) => {
const checked = hasGrant(u.id, f.id) ? "checked" : "";
return `
<td class="cell">
<label>
<input type="checkbox" data-perm-toggle
data-user="${u.id}" data-folder="${f.id}"
${checked} />
</label>
</td>
`;
}).join("")}
</tr>
`).join("");
bindPermToggles();
}
function bindPermToggles() {
document.querySelectorAll("[data-perm-toggle]").forEach((cb) => {
cb.addEventListener("change", handlePermToggle);
});
}
async function handlePermToggle(e) {
const cb = e.currentTarget;
const userId = cb.dataset.user;
const folderId = cb.dataset.folder;
const granted = cb.checked;
// Optimistically update local state
if (granted) {
if (!hasGrant(userId, folderId)) {
state.permissions.grants.push({
user_id: userId, folder_id: folderId,
granted_at: new Date().toISOString(),
});
}
} else {
state.permissions.grants = state.permissions.grants.filter(
(g) => !(g.user_id === userId && g.folder_id === folderId)
);
}
cb.disabled = true;
try {
await api(`/permissions/${userId}/${folderId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ granted }),
});
const folder = state.permissions.folders.find((f) => f.id === folderId);
const user = state.permissions.users.find((u) => u.id === userId);
toast(
granted
? `Granted "${user?.username}" access to "${folder?.name}"`
: `Revoked "${user?.username}" access to "${folder?.name}"`
);
} catch (err) {
// Revert on failure
cb.checked = !granted;
if (granted) {
state.permissions.grants = state.permissions.grants.filter(
(g) => !(g.user_id === userId && g.folder_id === folderId)
);
} else {
state.permissions.grants.push({
user_id: userId, folder_id: folderId,
granted_at: new Date().toISOString(),
});
}
toast(`Failed: ${err.message}`);
} finally {
cb.disabled = false;
}
}
function switchPermView(view) {
state.permView = view;
document.querySelectorAll(".view-btn").forEach((b) => {
b.classList.toggle("active", b.dataset.view === view);
});
renderPermissions();
}
// ---------- init ----------
function init() {
$("loginForm").addEventListener("submit", handleLogin);
$("logoutBtn").addEventListener("click", handleLogout);
$("newUserBtn").addEventListener("click", openCreateUser);
$("createUserForm").addEventListener("submit", handleCreateUser);
$("resetPasswordForm").addEventListener("submit", handleResetPassword);
$("mfaConfirmForm").addEventListener("submit", handleMfaConfirm);
// Tab switching
document.querySelectorAll(".admin-tab").forEach((el) => {
el.addEventListener("click", () => switchTab(el.dataset.tab));
});
// Permissions view switcher (by-user / by-folder)
document.querySelectorAll(".view-btn").forEach((el) => {
el.addEventListener("click", () => switchPermView(el.dataset.view));
});
// Folder modal
$("newFolderBtn").addEventListener("click", () => openFolderModal(null));
$("folderForm").addEventListener("submit", handleFolderSubmit);
["themeToggle", "themeToggleFloat"].forEach((id) => {
const el = document.getElementById(id);
if (el) el.addEventListener("click", toggleTheme);
});
document.querySelectorAll("[data-close]").forEach((el) => {
el.addEventListener("click", closeModals);
});
document.querySelectorAll(".modal").forEach((m) => {
m.addEventListener("click", (e) => {
if (e.target === m) closeModals();
});
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeModals();
});
bootstrap();
}
document.addEventListener("DOMContentLoaded", init);
@@ -0,0 +1,318 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PST Archive · Admin Panel</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,500;0,9..144,700;1,9..144,400&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/admin.css" />
<script>
(function() {
try {
var t = localStorage.getItem("pst-theme");
if (t !== "light" && t !== "dark") {
t = (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light";
}
document.documentElement.dataset.theme = t;
} catch (e) {
document.documentElement.dataset.theme = "light";
}
})();
</script>
</head>
<body>
<div class="grain"></div>
<!-- Floating theme toggle (visible on login/setup screens) -->
<button id="themeToggleFloat" class="theme-toggle theme-toggle-float" title="Toggle theme" aria-label="Toggle theme">
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4.5"/><line x1="12" y1="2" x2="12" y2="4.5"/><line x1="12" y1="19.5" x2="12" y2="22"/><line x1="2" y1="12" x2="4.5" y2="12"/><line x1="19.5" y1="12" x2="22" y2="12"/><line x1="4.9" y1="4.9" x2="6.7" y2="6.7"/><line x1="17.3" y1="17.3" x2="19.1" y2="19.1"/><line x1="4.9" y1="19.1" x2="6.7" y2="17.3"/><line x1="17.3" y1="6.7" x2="19.1" y2="4.9"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M20 14.5A9 9 0 019.5 4a7.5 7.5 0 1010.5 10.5z"/></svg>
</button>
<!-- FIRST-RUN NOTICE -->
<section id="setupNotice" class="auth-screen hidden">
<div class="auth-card">
<div class="auth-brand">
<span class="admin-badge">ADMIN</span>
<h1>Setup Required</h1>
<p class="auth-tagline">No administrator exists yet</p>
</div>
<div class="auth-form">
<p class="auth-intro">Please complete first-run setup in the main application before signing in here.</p>
<a id="gotoMainBtn" class="auth-btn" href="#">Open main app →</a>
</div>
</div>
</section>
<!-- LOGIN -->
<section id="loginScreen" class="auth-screen hidden">
<div class="auth-card">
<div class="auth-brand">
<span class="admin-badge">ADMIN</span>
<h1>PST Archive</h1>
<p class="auth-tagline">Admin panel · sign in</p>
</div>
<form id="loginForm" class="auth-form" novalidate>
<label class="auth-label">
<span>Username</span>
<input type="text" name="username" autocomplete="username" required />
</label>
<label class="auth-label">
<span>Password</span>
<input type="password" name="password" autocomplete="current-password" required />
</label>
<label id="mfaField" class="auth-label hidden">
<span>Authentication code</span>
<input type="text" name="totp_code" autocomplete="one-time-code" inputmode="numeric" pattern="[0-9]*" maxlength="6" placeholder="6-digit code" />
<small>From your authenticator app</small>
</label>
<button type="submit" class="auth-btn">Sign in</button>
<div id="loginError" class="auth-error hidden"></div>
</form>
</div>
</section>
<!-- ADMIN APP -->
<div id="adminApp" class="hidden">
<header class="masthead">
<div class="masthead-left">
<span class="admin-badge small">ADMIN</span>
<div class="masthead-title">
<h1>User Management</h1>
<p class="tagline">PST Archive · control panel</p>
</div>
</div>
<div class="masthead-right">
<span id="userChip" class="user-chip"></span>
<button id="themeToggle" class="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4.5"/><line x1="12" y1="2" x2="12" y2="4.5"/><line x1="12" y1="19.5" x2="12" y2="22"/><line x1="2" y1="12" x2="4.5" y2="12"/><line x1="19.5" y1="12" x2="22" y2="12"/><line x1="4.9" y1="4.9" x2="6.7" y2="6.7"/><line x1="17.3" y1="17.3" x2="19.1" y2="19.1"/><line x1="4.9" y1="19.1" x2="6.7" y2="17.3"/><line x1="17.3" y1="6.7" x2="19.1" y2="4.9"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M20 14.5A9 9 0 019.5 4a7.5 7.5 0 1010.5 10.5z"/></svg>
</button>
<button id="logoutBtn" class="logout-btn">Sign out</button>
</div>
</header>
<main class="admin-main">
<nav class="admin-tabs" role="tablist">
<button class="admin-tab active" data-tab="users" role="tab">Users</button>
<button class="admin-tab" data-tab="folders" role="tab">Folders</button>
<button class="admin-tab" data-tab="files" role="tab">Files</button>
<button class="admin-tab" data-tab="permissions" role="tab">Permissions</button>
</nav>
<!-- USERS TAB -->
<section class="admin-panel" data-panel="users">
<div class="admin-toolbar">
<div>
<h2 class="section-heading">Users</h2>
<p class="toolbar-meta" id="userCount"></p>
</div>
<button id="newUserBtn" class="primary-btn">+ New user</button>
</div>
<div class="user-list-wrap">
<table class="user-table">
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th>MFA</th>
<th>Created</th>
<th>Last login</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="userTableBody">
<tr><td colspan="6" class="loading-row">Loading users…</td></tr>
</tbody>
</table>
</div>
</section>
<!-- FOLDERS TAB -->
<section class="admin-panel hidden" data-panel="folders">
<div class="admin-toolbar">
<div>
<h2 class="section-heading">PST Folders</h2>
<p class="toolbar-meta" id="folderCount"></p>
</div>
<button id="newFolderBtn" class="primary-btn">+ New folder</button>
</div>
<div class="user-list-wrap">
<table class="user-table">
<thead>
<tr>
<th>Name</th>
<th>Archives inside</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="folderTableBody">
<tr><td colspan="4" class="loading-row">Loading folders…</td></tr>
</tbody>
</table>
</div>
</section>
<!-- FILES TAB -->
<section class="admin-panel hidden" data-panel="files">
<div class="admin-toolbar">
<div>
<h2 class="section-heading">PST Files</h2>
<p class="toolbar-meta" id="fileCount"></p>
</div>
</div>
<div class="user-list-wrap">
<table class="user-table">
<thead>
<tr>
<th>Filename</th>
<th>Folder</th>
<th>Size</th>
<th>Status</th>
<th>Messages</th>
<th>Uploaded</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="fileTableBody">
<tr><td colspan="7" class="loading-row">Loading files…</td></tr>
</tbody>
</table>
</div>
</section>
<!-- PERMISSIONS TAB -->
<section class="admin-panel hidden" data-panel="permissions">
<div class="admin-toolbar">
<div>
<h2 class="section-heading">Folder Access Permissions</h2>
<p class="toolbar-meta" id="permissionsMeta"></p>
</div>
<div class="view-switch" role="tablist">
<button class="view-btn active" data-view="by-user" type="button">By user</button>
<button class="view-btn" data-view="by-folder" type="button">By folder</button>
</div>
</div>
<p class="perm-hint">
Check a box to grant a non-admin user access to a folder. Administrators
always see every folder regardless of these settings. "Uncategorized" archives
are visible to everyone by default.
</p>
<div class="perm-matrix-wrap">
<table class="user-table perm-matrix" id="permMatrix">
<thead id="permMatrixHead"></thead>
<tbody id="permMatrixBody">
<tr><td class="loading-row">Loading…</td></tr>
</tbody>
</table>
</div>
</section>
</main>
</div>
<!-- CREATE USER MODAL -->
<div id="createUserModal" class="modal hidden">
<div class="modal-card">
<button class="modal-close" data-close>×</button>
<h2>Create new user</h2>
<form id="createUserForm" class="auth-form" novalidate>
<label class="auth-label">
<span>Username</span>
<input type="text" name="username" autocomplete="off" required minlength="3" maxlength="64" />
</label>
<label class="auth-label">
<span>Password</span>
<input type="password" name="password" autocomplete="new-password" required minlength="8" />
<small>Minimum 8 characters</small>
</label>
<label class="auth-label">
<span>Role</span>
<select name="role">
<option value="user">User (can view archives)</option>
<option value="admin">Administrator (full access)</option>
</select>
</label>
<button type="submit" class="auth-btn">Create user</button>
<div id="createError" class="auth-error hidden"></div>
</form>
</div>
</div>
<!-- RESET PASSWORD MODAL -->
<div id="resetPasswordModal" class="modal hidden">
<div class="modal-card">
<button class="modal-close" data-close>×</button>
<h2>Reset password</h2>
<p class="modal-subtitle" id="resetPasswordSubject"></p>
<form id="resetPasswordForm" class="auth-form" novalidate>
<label class="auth-label">
<span>New password</span>
<input type="password" name="password" autocomplete="new-password" required minlength="8" />
<small>Minimum 8 characters</small>
</label>
<button type="submit" class="auth-btn">Update password</button>
<div id="resetError" class="auth-error hidden"></div>
</form>
</div>
</div>
<!-- MFA MODAL -->
<div id="mfaModal" class="modal hidden">
<div class="modal-card wide">
<button class="modal-close" data-close>×</button>
<h2>Multi-factor authentication</h2>
<p class="modal-subtitle" id="mfaSubject"></p>
<div id="mfaPanel" class="mfa-panel">
<div class="mfa-status" id="mfaCurrentStatus"></div>
<div class="mfa-actions" id="mfaActions"></div>
</div>
<div id="mfaEnrollPanel" class="mfa-enroll hidden">
<div class="enroll-grid">
<div class="enroll-qr">
<img id="mfaQrImage" src="" alt="TOTP QR code" />
</div>
<div class="enroll-details">
<p class="enroll-step">1. Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.).</p>
<p class="enroll-step">2. Or manually enter this secret:</p>
<code id="mfaSecretText" class="secret-box"></code>
<p class="enroll-step">3. Enter the 6-digit code from the app to confirm.</p>
<form id="mfaConfirmForm" class="auth-form confirm-form">
<label class="auth-label">
<span>Verification code</span>
<input type="text" name="totp_code" inputmode="numeric" pattern="[0-9]*" maxlength="6" required />
</label>
<button type="submit" class="auth-btn">Confirm & enable</button>
<div id="mfaConfirmError" class="auth-error hidden"></div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- FOLDER CREATE/RENAME MODAL -->
<div id="folderModal" class="modal hidden">
<div class="modal-card">
<button class="modal-close" data-close>×</button>
<h2 id="folderModalTitle">New folder</h2>
<form id="folderForm" class="auth-form" novalidate>
<label class="auth-label">
<span>Folder name</span>
<input type="text" name="name" maxlength="80" required autocomplete="off" />
</label>
<button type="submit" class="auth-btn" id="folderSubmitBtn">Create folder</button>
<div id="folderError" class="auth-error hidden"></div>
</form>
</div>
</div>
<div id="toast" class="toast hidden"></div>
<script src="/admin.js"></script>
</body>
</html>
@@ -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")
@@ -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, '<mark>', '</mark>', '', 24) AS snippet "
+ base + " ORDER BY rank LIMIT ? OFFSET ?"
)
rows = conn.execute(data_sql, params + [limit, offset]).fetchall()
else:
where = ["m.pst_id = ?"]
if folder:
where.append("m.folder = ?")
params.append(folder)
where_sql = " AND ".join(where)
total = conn.execute(
f"SELECT COUNT(*) as c FROM messages m WHERE {where_sql}", params
).fetchone()["c"]
rows = conn.execute(
f"""SELECT m.id, m.folder, m.subject, m.sender, m.recipients,
m.sent_date, m.attachments, substr(m.body, 1, 200) AS snippet
FROM messages m WHERE {where_sql}
ORDER BY m.sent_date DESC LIMIT ? OFFSET ?""",
params + [limit, offset],
).fetchall()
return {"total": total, "limit": limit, "offset": offset,
"items": [dict(r) for r in rows]}
@app.get("/api/messages/{message_id}")
def get_message(message_id: int, user: User = Depends(require_app_user)):
with get_db() as conn:
row = conn.execute(
"""SELECT id, pst_id, folder, subject, sender, recipients,
sent_date, body, attachments
FROM messages WHERE id = ?""",
(message_id,),
).fetchone()
if not row:
raise HTTPException(404, "Message not found")
if not user_can_see_pst(user, row["pst_id"]):
raise HTTPException(404, "Message not found")
return dict(row)
# ---------------------------------------------------------------------------
# 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")
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function renderSnippet(snippet) {
if (!snippet) return "";
return escapeHtml(snippet)
.replace(/&lt;mark&gt;/g, "<mark>")
.replace(/&lt;\/mark&gt;/g, "</mark>");
}
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 `
<li class="pst-item ${active}" data-id="${i.id}" data-name="${escapeHtml(i.original_name)}" data-status="${i.status}" ${disabled ? 'data-disabled="1"' : ""}>
<div class="pst-name">
<span class="pst-name-text" title="${escapeHtml(i.original_name)}">${escapeHtml(i.original_name)}</span>
<button class="pst-menu" data-pst-menu="${i.id}" title="Actions">⋯</button>
</div>
<div class="pst-meta">
<span>${fmtBytes(i.size_bytes)} · ${fmtDate(i.uploaded_at)}</span>
<span class="pst-status ${i.status}">${statusText}</span>
</div>
</li>
`;
}
function renderPstList() {
const ul = $("pstList");
const { folders, psts } = state;
if (!folders.length && !psts.length) {
ul.innerHTML = '<li class="empty-state">No archives yet.</li>';
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(`
<li class="folder-group ${collapsed}" data-folder="${f.id}">
<div class="folder-header" data-folder-header="${f.id}">
<div class="folder-header-left">
<span class="folder-caret">▾</span>
<span class="folder-name" title="${escapeHtml(f.name)}">${escapeHtml(f.name)}</span>
<span class="folder-count">${items.length}</span>
</div>
<button class="folder-menu-btn" data-folder-menu="${f.id}" title="Folder actions">⋯</button>
</div>
<ul class="folder-items">
${items.length
? items.map(pstItemHtml).join("")
: '<li class="folder-empty">— empty —</li>'}
</ul>
</li>
`);
}
// 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(`
<li class="folder-group ${collapsed}" data-folder="__uncat__">
<div class="folder-header" data-folder-header="__uncat__">
<div class="folder-header-left">
<span class="folder-caret">▾</span>
<span class="folder-name">Uncategorized</span>
<span class="folder-count">${uncat.length}</span>
</div>
</div>
<ul class="folder-items">
${uncat.length
? uncat.map(pstItemHtml).join("")
: '<li class="folder-empty">— empty —</li>'}
</ul>
</li>
`);
}
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 = '<option value="">Uncategorized</option>' +
state.folders.map((f) =>
`<option value="${f.id}" ${f.id === p.folder_id ? "selected" : ""}>${escapeHtml(f.name)}</option>`
).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 =
'<option value="">All folders</option>' +
folders.map((f) => `<option value="${escapeHtml(f.folder || "")}">${escapeHtml(f.folder || "(root)")} · ${f.count}</option>`).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 = `
<div class="reader-placeholder">
<div class="placeholder-ornament">∅</div>
<p>No messages match.</p>
</div>`;
return;
}
list.innerHTML = items.map((m) => {
const hasAtt = m.attachments && m.attachments.trim().length > 0;
return `
<div class="message-row" data-id="${m.id}">
<div class="mr-sender" title="${escapeHtml(m.sender || "")}">${escapeHtml(m.sender || "(unknown sender)")}</div>
<div class="mr-main">
<div class="mr-subject">${escapeHtml(m.subject || "(no subject)")} ${hasAtt ? '<span class="mr-attached" title="Has attachments">◈</span>' : ""}</div>
<div class="mr-snippet">${renderSnippet(m.snippet || "")}</div>
</div>
<div class="mr-meta">
<div>${fmtDate(m.sent_date)}</div>
<div class="mr-folder" title="${escapeHtml(m.folder || "")}">${escapeHtml((m.folder || "").split("/").filter(Boolean).pop() || "inbox")}</div>
</div>
</div>`;
}).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 = `
<div class="reader-placeholder">
<div class="placeholder-ornament">✦</div>
<p>The archive awaits.</p>
</div>`;
$("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);
@@ -0,0 +1,256 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PST Archive · Mail Indexer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,500;0,9..144,700;1,9..144,400&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css" />
<script>
// Inline theme bootstrap — runs before CSS so no flash of wrong theme
(function() {
try {
var t = localStorage.getItem("pst-theme");
if (t !== "light" && t !== "dark") {
t = (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light";
}
document.documentElement.dataset.theme = t;
} catch (e) {
document.documentElement.dataset.theme = "light";
}
})();
</script>
</head>
<body>
<div class="grain"></div>
<!-- Floating theme toggle (visible on all screens) -->
<button id="themeToggleFloat" class="theme-toggle theme-toggle-float" title="Toggle theme" aria-label="Toggle theme">
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4.5"/><line x1="12" y1="2" x2="12" y2="4.5"/><line x1="12" y1="19.5" x2="12" y2="22"/><line x1="2" y1="12" x2="4.5" y2="12"/><line x1="19.5" y1="12" x2="22" y2="12"/><line x1="4.9" y1="4.9" x2="6.7" y2="6.7"/><line x1="17.3" y1="17.3" x2="19.1" y2="19.1"/><line x1="4.9" y1="19.1" x2="6.7" y2="17.3"/><line x1="17.3" y1="6.7" x2="19.1" y2="4.9"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M20 14.5A9 9 0 019.5 4a7.5 7.5 0 1010.5 10.5z"/></svg>
</button>
<!-- SETUP WIZARD -->
<section id="setupScreen" class="auth-screen hidden">
<div class="auth-card">
<div class="auth-brand">
<svg viewBox="0 0 40 40" width="44" height="44" aria-hidden="true">
<rect x="3" y="8" width="34" height="26" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M3 8 L20 22 L37 8" fill="none" stroke="currentColor" stroke-width="1.5"/>
<line x1="3" y1="34" x2="14" y2="22" stroke="currentColor" stroke-width="1.5"/>
<line x1="37" y1="34" x2="26" y2="22" stroke="currentColor" stroke-width="1.5"/>
</svg>
<h1>PST Archive</h1>
<p class="auth-tagline">First-run setup</p>
</div>
<form id="setupForm" class="auth-form" novalidate>
<p class="auth-intro">Create the primary administrator account. This user will have full access to the archive and the admin panel.</p>
<label class="auth-label">
<span>Username</span>
<input type="text" name="username" autocomplete="username" required minlength="3" maxlength="64" />
</label>
<label class="auth-label">
<span>Password</span>
<input type="password" name="password" autocomplete="new-password" required minlength="8" />
<small>Minimum 8 characters. Use a passphrase you will remember.</small>
</label>
<label class="auth-label">
<span>Confirm password</span>
<input type="password" name="password2" autocomplete="new-password" required minlength="8" />
</label>
<button type="submit" class="auth-btn">Create administrator</button>
<div id="setupError" class="auth-error hidden"></div>
</form>
</div>
</section>
<!-- LOGIN -->
<section id="loginScreen" class="auth-screen hidden">
<div class="auth-card">
<div class="auth-brand">
<svg viewBox="0 0 40 40" width="44" height="44" aria-hidden="true">
<rect x="3" y="8" width="34" height="26" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M3 8 L20 22 L37 8" fill="none" stroke="currentColor" stroke-width="1.5"/>
<line x1="3" y1="34" x2="14" y2="22" stroke="currentColor" stroke-width="1.5"/>
<line x1="37" y1="34" x2="26" y2="22" stroke="currentColor" stroke-width="1.5"/>
</svg>
<h1>PST Archive</h1>
<p class="auth-tagline">Sign in to continue</p>
</div>
<form id="loginForm" class="auth-form" novalidate>
<label class="auth-label">
<span>Username</span>
<input type="text" name="username" autocomplete="username" required />
</label>
<label class="auth-label">
<span>Password</span>
<input type="password" name="password" autocomplete="current-password" required />
</label>
<label id="mfaField" class="auth-label hidden">
<span>Authentication code</span>
<input type="text" name="totp_code" autocomplete="one-time-code" inputmode="numeric" pattern="[0-9]*" maxlength="6" placeholder="6-digit code" />
<small>From your authenticator app</small>
</label>
<button type="submit" class="auth-btn">Sign in</button>
<div id="loginError" class="auth-error hidden"></div>
</form>
</div>
</section>
<!-- MAIN APP -->
<div id="mainApp" class="hidden">
<header class="masthead">
<div class="masthead-left">
<div class="logo-mark">
<svg viewBox="0 0 40 40" width="36" height="36" aria-hidden="true">
<rect x="3" y="8" width="34" height="26" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M3 8 L20 22 L37 8" fill="none" stroke="currentColor" stroke-width="1.5"/>
<line x1="3" y1="34" x2="14" y2="22" stroke="currentColor" stroke-width="1.5"/>
<line x1="37" y1="34" x2="26" y2="22" stroke="currentColor" stroke-width="1.5"/>
</svg>
</div>
<div class="masthead-title">
<h1>PST Archive</h1>
<p class="tagline">Forensic mail indexer · v1.0</p>
</div>
</div>
<div class="masthead-right">
<span id="statusDot" class="status-dot"></span>
<span id="statusText" class="status-text">connecting…</span>
<span id="userChip" class="user-chip"></span>
<button id="themeToggle" class="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4.5"/><line x1="12" y1="2" x2="12" y2="4.5"/><line x1="12" y1="19.5" x2="12" y2="22"/><line x1="2" y1="12" x2="4.5" y2="12"/><line x1="19.5" y1="12" x2="22" y2="12"/><line x1="4.9" y1="4.9" x2="6.7" y2="6.7"/><line x1="17.3" y1="17.3" x2="19.1" y2="19.1"/><line x1="4.9" y1="19.1" x2="6.7" y2="17.3"/><line x1="17.3" y1="6.7" x2="19.1" y2="4.9"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M20 14.5A9 9 0 019.5 4a7.5 7.5 0 1010.5 10.5z"/></svg>
</button>
<button id="logoutBtn" class="logout-btn" title="Sign out">Sign out</button>
</div>
</header>
<main class="workspace">
<aside class="sidebar">
<section class="upload-block">
<h2 class="section-heading">01 · Ingest</h2>
<label class="dropzone" id="dropzone">
<input type="file" id="fileInput" accept=".pst,.ost" hidden />
<div class="dropzone-inner">
<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span class="dropzone-title">Drop <em>.pst</em> / <em>.ost</em></span>
<span class="dropzone-hint">or click to browse</span>
</div>
</label>
<div id="uploadProgress" class="upload-progress hidden">
<div class="progress-label"><span id="progressName"></span> <span id="progressPct">0%</span></div>
<div class="progress-track"><div id="progressBar" class="progress-bar"></div></div>
</div>
</section>
<section class="archive-block">
<div class="archive-head">
<h2 class="section-heading">02 · Archives</h2>
<div class="archive-actions">
<button id="newFolderBtn" class="icon-btn" title="New folder">
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" fill="none" stroke="currentColor" stroke-width="1.5"/><line x1="12" y1="11" x2="12" y2="17" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><line x1="9" y1="14" x2="15" y2="14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
<button id="refreshBtn" class="icon-btn" title="Refresh list">
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M4 4v6h6M20 20v-6h-6M20 10a8 8 0 00-14.9-3M4 14a8 8 0 0014.9 3" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
</div>
</div>
<ul id="pstList" class="pst-list">
<li class="empty-state">No archives yet.</li>
</ul>
</section>
</aside>
<section class="reader">
<div class="reader-head">
<div class="reader-title">
<h2 id="readerTitle">Select an archive</h2>
<p id="readerMeta" class="reader-meta">Upload a PST file or select one from the left to begin.</p>
</div>
<div class="search-wrap">
<input id="searchInput" type="search" placeholder="Search subject, sender, body…" disabled />
<select id="folderFilter" disabled>
<option value="">All folders</option>
</select>
</div>
</div>
<div class="reader-body">
<div id="messageList" class="message-list">
<div class="reader-placeholder">
<div class="placeholder-ornament"></div>
<p>The archive awaits.</p>
</div>
</div>
<article id="messageView" class="message-view hidden">
<button id="closeMessageBtn" class="close-btn" title="Close">×</button>
<header class="message-header">
<div class="message-subject" id="mvSubject"></div>
<dl class="message-meta">
<dt>From</dt><dd id="mvSender"></dd>
<dt>To</dt><dd id="mvRecipients"></dd>
<dt>Date</dt><dd id="mvDate"></dd>
<dt>Folder</dt><dd id="mvFolder"></dd>
<dt id="mvAttLabel" class="hidden">Attachments</dt>
<dd id="mvAttachments" class="hidden"></dd>
</dl>
</header>
<div class="message-body" id="mvBody"></div>
</article>
</div>
<div id="paginator" class="paginator hidden">
<button id="prevBtn">← Previous</button>
<span id="pageInfo"></span>
<button id="nextBtn">Next →</button>
</div>
</section>
</main>
</div>
<div id="toast" class="toast hidden"></div>
<!-- NEW FOLDER MODAL -->
<div id="folderModal" class="modal hidden">
<div class="modal-card">
<button class="modal-close" data-close>×</button>
<h2>New folder</h2>
<form id="folderForm" class="auth-form" novalidate>
<label class="auth-label">
<span>Folder name</span>
<input type="text" name="name" maxlength="80" required autocomplete="off" />
</label>
<button type="submit" class="auth-btn">Create folder</button>
<div id="folderError" class="auth-error hidden"></div>
</form>
</div>
</div>
<!-- MOVE PST MODAL -->
<div id="moveModal" class="modal hidden">
<div class="modal-card">
<button class="modal-close" data-close>×</button>
<h2>Move archive</h2>
<p class="modal-subtitle" id="moveSubject"></p>
<form id="moveForm" class="auth-form" novalidate>
<label class="auth-label">
<span>Folder</span>
<select name="folder_id" id="moveFolderSelect">
<option value="">Uncategorized</option>
</select>
</label>
<button type="submit" class="auth-btn">Move</button>
<div id="moveError" class="auth-error hidden"></div>
</form>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff