Jul 22 - Update - add audit viewer, CSRF lifetime

This commit is contained in:
2026-07-22 16:59:46 -04:00
parent 8533cbe27c
commit a39fa091fa
8 changed files with 170 additions and 2 deletions
+2
View File
@@ -20,3 +20,5 @@ ADMIN_PASSWORD_HASH=
SESSION_COOKIE_SECURE=1 SESSION_COOKIE_SECURE=1
# Where the auth log fail2ban watches is written. Blank -> <appdir>/logs/auth.log # Where the auth log fail2ban watches is written. Blank -> <appdir>/logs/auth.log
AUTH_LOG_PATH= AUTH_LOG_PATH=
# CSRF token lifetime (seconds). Blank -> token lasts the whole session (no 1h expiry).
WTF_CSRF_TIME_LIMIT=
+5 -1
View File
@@ -27,7 +27,8 @@ Ubuntu 24.04. Fonts via Google Fonts (Bricolage Grotesque, IBM Plex Sans/Mono).
- `app.py` — factory, models (`Section`, `Topic`, `AuditLog`), `log_action()`, - `app.py` — factory, models (`Section`, `Topic`, `AuditLog`), `log_action()`,
ProxyFix wrap, `_configure_auth_logger()`, public routes `/`, `/healthz`. ProxyFix wrap, `_configure_auth_logger()`, public routes `/`, `/healthz`.
- `admin.py` — blueprint `/admin`: session login, section/topic CRUD. - `admin.py` — blueprint `/admin`: session login, section/topic CRUD, `/admin/audit`
read-only audit viewer (filter by action/entity, 50/page).
- `config.py` — env-driven config + `_load_dotenv()` (no-expansion loader). - `config.py` — env-driven config + `_load_dotenv()` (no-expansion loader).
- `templates/` — public `index.html`; `admin/` base+login+dashboard+forms. - `templates/` — public `index.html`; `admin/` base+login+dashboard+forms.
- `static/css/style.css` (public), `static/css/admin.css`, `static/js/main.js`. - `static/css/style.css` (public), `static/css/admin.css`, `static/js/main.js`.
@@ -78,6 +79,9 @@ Public page orders sections by `sort_order, num`; topics by `sort_order`.
local HTTP testing. local HTTP testing.
- **Empty `SECRET_KEY` also kills sessions** → same CSRF error. Must be set, - **Empty `SECRET_KEY` also kills sessions** → same CSRF error. Must be set,
stable, secret. Changing it logs everyone out. stable, secret. Changing it logs everyone out.
- **CSRF token 1h default 400s long edits.** `WTF_CSRF_TIME_LIMIT` config: blank
→ `None` (token valid for the whole session, cookie-bound); integer overrides.
`audit_log.created_at` is UTC (`datetime.utcnow`); the viewer labels it UTC.
- **systemd reads `EnvironmentFile` only at start** → `systemctl restart` after - **systemd reads `EnvironmentFile` only at start** → `systemctl restart` after
any `.env` edit; `daemon-reload` after unit edits. any `.env` edit; `daemon-reload` after unit edits.
- **Real client IP behind nginx:** `request.remote_addr` is 127.0.0.1 without - **Real client IP behind nginx:** `request.remote_addr` is 127.0.0.1 without
+5
View File
@@ -171,12 +171,17 @@ Then visit `https://your-domain/admin`, sign in, and manage content.
(image / video / embed), caption, an optional link button, and sort order. (image / video / embed), caption, an optional link button, and sort order.
- **Add / edit section** — number (`§NN`, unique), title, subtitle, sort order. - **Add / edit section** — number (`§NN`, unique), title, subtitle, sort order.
- Deleting a section cascades to its topics (with a confirm prompt). - Deleting a section cascades to its topics (with a confirm prompt).
- **Audit log** (`/admin/audit`, "Audit" in the nav) — read-only view of every
change, newest first, filterable by action and type, paginated 50/page. Times
are UTC.
### Notes ### Notes
- `SESSION_COOKIE_SECURE=1` means the login cookie only sends over HTTPS. For a - `SESSION_COOKIE_SECURE=1` means the login cookie only sends over HTTPS. For a
quick plain-HTTP test on the box, set it to `0` — never in production. quick plain-HTTP test on the box, set it to `0` — never in production.
- `SECRET_KEY` must be stable and secret; changing it logs everyone out. - `SECRET_KEY` must be stable and secret; changing it logs everyone out.
- `WTF_CSRF_TIME_LIMIT` blank keeps a token valid for the whole session, so a
long edit never 400s on save; set an integer (seconds) to re-enable expiry.
- The admin routes live under `/admin`; the public page and `schema.sql` SQL - The admin routes live under `/admin`; the public page and `schema.sql` SQL
workflow above still work unchanged. workflow above still work unchanged.
+30 -1
View File
@@ -9,7 +9,7 @@ from flask import (
) )
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from app import db, log_action, Section, Topic from app import db, log_action, AuditLog, Section, Topic
admin_bp = Blueprint("admin", __name__, url_prefix="/admin") admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
@@ -108,6 +108,35 @@ def dashboard():
return render_template("admin/dashboard.html", sections=sections) return render_template("admin/dashboard.html", sections=sections)
# ------------------------------------------------------------------ audit log
@admin_bp.route("/audit")
@login_required
def audit():
page = _int(request.args.get("page"), 1)
if page < 1:
page = 1
action = request.args.get("action", "")
entity = request.args.get("entity", "")
q = AuditLog.query
if action in ("create", "update", "delete"):
q = q.filter(AuditLog.action == action)
if entity in ("section", "topic"):
q = q.filter(AuditLog.entity == entity)
pagination = (
q.order_by(AuditLog.created_at.desc(), AuditLog.id.desc())
.paginate(page=page, per_page=50, error_out=False)
)
return render_template(
"admin/audit.html",
pagination=pagination,
entries=pagination.items,
action=action,
entity=entity,
)
# ------------------------------------------------------------------ topics # ------------------------------------------------------------------ topics
@admin_bp.route("/topic/new", methods=["GET", "POST"]) @admin_bp.route("/topic/new", methods=["GET", "POST"])
@admin_bp.route("/topic/<int:topic_id>", methods=["GET", "POST"]) @admin_bp.route("/topic/<int:topic_id>", methods=["GET", "POST"])
+6
View File
@@ -35,3 +35,9 @@ class Config:
# Path to the auth log fail2ban watches. Empty -> <appdir>/logs/auth.log # Path to the auth log fail2ban watches. Empty -> <appdir>/logs/auth.log
AUTH_LOG_PATH = os.environ.get("AUTH_LOG_PATH", "") AUTH_LOG_PATH = os.environ.get("AUTH_LOG_PATH", "")
# CSRF token lifetime in seconds. Flask-WTF defaults to 3600 (1h), which can
# 400 a long edit on save. Blank/None -> token is valid for the whole session
# (still single-use-per-session and cookie-bound). Set an integer to override.
_csrf_ttl = os.environ.get("WTF_CSRF_TIME_LIMIT", "").strip()
WTF_CSRF_TIME_LIMIT = int(_csrf_ttl) if _csrf_ttl.isdigit() else None
+47
View File
@@ -173,3 +173,50 @@ form{display:inline}
.grid-2{grid-template-columns:1fr} .grid-2{grid-template-columns:1fr}
.page-head{flex-direction:column} .page-head{flex-direction:column}
} }
/* audit log */
.filters{
display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:18px;
}
.filters__label{
font-family:"IBM Plex Mono",monospace;font-size:.72rem;letter-spacing:.1em;
text-transform:uppercase;color:var(--muted);margin-right:4px;
}
.filters__sep{color:var(--hair);margin:0 2px}
.filter-chip{
font-size:.82rem;text-decoration:none;color:var(--ink-soft);
padding:5px 12px;border:1px solid var(--hair);border-radius:100px;background:var(--card);
transition:border-color .18s,background .18s,color .18s;
}
.filter-chip:hover{border-color:var(--aqua)}
.filter-chip.is-active{background:var(--ink);border-color:var(--ink);color:#fff}
.table-wrap{
background:var(--card);border:1px solid var(--hair);border-radius:var(--radius);
overflow-x:auto;
}
.audit-table{width:100%;border-collapse:collapse;font-size:.9rem}
.audit-table th{
text-align:left;font-size:.72rem;letter-spacing:.06em;text-transform:uppercase;
color:var(--muted);font-weight:600;padding:12px 14px;border-bottom:1px solid var(--hair);
background:var(--hair-2);white-space:nowrap;
}
.audit-table td{padding:11px 14px;border-bottom:1px solid var(--hair-2);vertical-align:top}
.audit-table tr:last-child td{border-bottom:0}
.mono{font-family:"IBM Plex Mono",monospace;font-size:.82rem}
.nowrap{white-space:nowrap}
.badge{
display:inline-block;font-family:"IBM Plex Mono",monospace;font-size:.7rem;
letter-spacing:.04em;text-transform:uppercase;padding:3px 9px;border-radius:6px;
border:1px solid transparent;
}
.badge--create{background:#E7F6F4;color:#0A5A54;border-color:#B9E6E0}
.badge--update{background:#FBF0E1;color:#8A5A12;border-color:#F0D9B3}
.badge--delete{background:#FBEAE6;color:#9C3325;border-color:#F0C7BE}
.pager{display:flex;align-items:center;justify-content:center;gap:14px;margin-top:20px}
.pager__info{
font-family:"IBM Plex Mono",monospace;font-size:.8rem;color:var(--muted);
}
.btn.is-disabled{opacity:.4;cursor:default;pointer-events:none}
+74
View File
@@ -0,0 +1,74 @@
{% extends "admin/base.html" %}
{% block title %}Audit log{% endblock %}
{% block content %}
<header class="page-head">
<div>
<h1>Audit log</h1>
<p class="muted">Every content change, newest first. Times are UTC.</p>
</div>
<a class="btn btn--ghost" href="{{ url_for('admin.dashboard') }}">← Dashboard</a>
</header>
<div class="filters">
<span class="filters__label">Filter</span>
{% set actions = [('', 'All actions'), ('create','create'), ('update','update'), ('delete','delete')] %}
{% for val, label in actions %}
<a class="filter-chip {{ 'is-active' if action == val }}"
href="{{ url_for('admin.audit', action=val, entity=entity) }}">{{ label }}</a>
{% endfor %}
<span class="filters__sep">·</span>
{% set entities = [('', 'All types'), ('section','section'), ('topic','topic')] %}
{% for val, label in entities %}
<a class="filter-chip {{ 'is-active' if entity == val }}"
href="{{ url_for('admin.audit', action=action, entity=val) }}">{{ label }}</a>
{% endfor %}
</div>
{% if entries %}
<div class="table-wrap">
<table class="audit-table">
<thead>
<tr>
<th>When (UTC)</th>
<th>Actor</th>
<th>Action</th>
<th>Type</th>
<th>ID</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{% for e in entries %}
<tr>
<td class="mono nowrap">{{ e.created_at.strftime('%Y-%m-%d %H:%M:%S') if e.created_at else '—' }}</td>
<td>{{ e.actor or '—' }}</td>
<td><span class="badge badge--{{ e.action }}">{{ e.action }}</span></td>
<td>{{ e.entity }}</td>
<td class="mono">{{ e.entity_id if e.entity_id is not none else '—' }}</td>
<td>{{ e.detail or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pagination.pages > 1 %}
<nav class="pager">
{% if pagination.has_prev %}
<a class="btn btn--ghost btn--sm" href="{{ url_for('admin.audit', page=pagination.prev_num, action=action, entity=entity) }}">← Newer</a>
{% else %}
<span class="btn btn--ghost btn--sm is-disabled">← Newer</span>
{% endif %}
<span class="pager__info">Page {{ pagination.page }} of {{ pagination.pages }}</span>
{% if pagination.has_next %}
<a class="btn btn--ghost btn--sm" href="{{ url_for('admin.audit', page=pagination.next_num, action=action, entity=entity) }}">Older →</a>
{% else %}
<span class="btn btn--ghost btn--sm is-disabled">Older →</span>
{% endif %}
</nav>
{% endif %}
{% else %}
<div class="empty">No audit entries{{ ' for this filter' if action or entity }}.</div>
{% endif %}
{% endblock %}
+1
View File
@@ -14,6 +14,7 @@
<nav class="anav"> <nav class="anav">
<a class="anav__brand" href="{{ url_for('admin.dashboard') }}">JQC<span>admin</span></a> <a class="anav__brand" href="{{ url_for('admin.dashboard') }}">JQC<span>admin</span></a>
<div class="anav__right"> <div class="anav__right">
<a class="anav__link" href="{{ url_for('admin.audit') }}">Audit</a>
<a class="anav__link" href="{{ url_for('index') }}" target="_blank" rel="noopener">View site ↗</a> <a class="anav__link" href="{{ url_for('index') }}" target="_blank" rel="noopener">View site ↗</a>
<span class="anav__user">{{ session.get('admin') }}</span> <span class="anav__user">{{ session.get('admin') }}</span>
<form method="post" action="{{ url_for('admin.logout') }}"> <form method="post" action="{{ url_for('admin.logout') }}">