From a39fa091fac39e3544bca63b3e04445f007e34a7 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 22 Jul 2026 16:59:46 -0400 Subject: [PATCH] Jul 22 - Update - add audit viewer, CSRF lifetime --- .env.example | 2 ++ CLAUDE.md | 6 +++- README.md | 5 +++ admin.py | 31 +++++++++++++++- config.py | 6 ++++ static/css/admin.css | 47 ++++++++++++++++++++++++ templates/admin/audit.html | 74 ++++++++++++++++++++++++++++++++++++++ templates/admin/base.html | 1 + 8 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 templates/admin/audit.html diff --git a/.env.example b/.env.example index d579144..0b17796 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,5 @@ ADMIN_PASSWORD_HASH= SESSION_COOKIE_SECURE=1 # Where the auth log fail2ban watches is written. Blank -> /logs/auth.log AUTH_LOG_PATH= +# CSRF token lifetime (seconds). Blank -> token lasts the whole session (no 1h expiry). +WTF_CSRF_TIME_LIMIT= diff --git a/CLAUDE.md b/CLAUDE.md index 2b6712e..7fada69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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()`, 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). - `templates/` — public `index.html`; `admin/` base+login+dashboard+forms. - `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. - **Empty `SECRET_KEY` also kills sessions** → same CSRF error. Must be set, 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 any `.env` edit; `daemon-reload` after unit edits. - **Real client IP behind nginx:** `request.remote_addr` is 127.0.0.1 without diff --git a/README.md b/README.md index 94b2cf8..6440111 100644 --- a/README.md +++ b/README.md @@ -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. - **Add / edit section** — number (`§NN`, unique), title, subtitle, sort order. - 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 - `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. - `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 workflow above still work unchanged. diff --git a/admin.py b/admin.py index 301e70a..bdb48bf 100644 --- a/admin.py +++ b/admin.py @@ -9,7 +9,7 @@ from flask import ( ) 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") @@ -108,6 +108,35 @@ def dashboard(): 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 @admin_bp.route("/topic/new", methods=["GET", "POST"]) @admin_bp.route("/topic/", methods=["GET", "POST"]) diff --git a/config.py b/config.py index b2608e0..8110353 100644 --- a/config.py +++ b/config.py @@ -35,3 +35,9 @@ class Config: # Path to the auth log fail2ban watches. Empty -> /logs/auth.log 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 diff --git a/static/css/admin.css b/static/css/admin.css index 9d5393a..61eac91 100644 --- a/static/css/admin.css +++ b/static/css/admin.css @@ -173,3 +173,50 @@ form{display:inline} .grid-2{grid-template-columns:1fr} .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} diff --git a/templates/admin/audit.html b/templates/admin/audit.html new file mode 100644 index 0000000..8e2a21f --- /dev/null +++ b/templates/admin/audit.html @@ -0,0 +1,74 @@ +{% extends "admin/base.html" %} +{% block title %}Audit log{% endblock %} +{% block content %} +
+
+

Audit log

+

Every content change, newest first. Times are UTC.

+
+ ← Dashboard +
+ +
+ Filter + {% set actions = [('', 'All actions'), ('create','create'), ('update','update'), ('delete','delete')] %} + {% for val, label in actions %} + {{ label }} + {% endfor %} + · + {% set entities = [('', 'All types'), ('section','section'), ('topic','topic')] %} + {% for val, label in entities %} + {{ label }} + {% endfor %} +
+ +{% if entries %} +
+ + + + + + + + + + + + + {% for e in entries %} + + + + + + + + + {% endfor %} + +
When (UTC)ActorActionTypeIDDetail
{{ e.created_at.strftime('%Y-%m-%d %H:%M:%S') if e.created_at else '—' }}{{ e.actor or '—' }}{{ e.action }}{{ e.entity }}{{ e.entity_id if e.entity_id is not none else '—' }}{{ e.detail or '' }}
+
+ +{% if pagination.pages > 1 %} + +{% endif %} + +{% else %} +
No audit entries{{ ' for this filter' if action or entity }}.
+{% endif %} +{% endblock %} diff --git a/templates/admin/base.html b/templates/admin/base.html index d7b2e63..a33924a 100644 --- a/templates/admin/base.html +++ b/templates/admin/base.html @@ -14,6 +14,7 @@