Files

460 lines
16 KiB
Python

import hmac
import logging
import os
import re
import uuid
from functools import wraps
from flask import (
Blueprint, abort, current_app, flash, jsonify, redirect, render_template,
request, session, url_for,
)
from werkzeug.security import check_password_hash
from app import (
db, log_action, sanitize_html, AuditLog, DemoRequest, DEMO_STATUSES,
Section, Topic,
)
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
auth_log = logging.getLogger("jqc.auth")
MEDIA_TYPES = ("none", "image", "video", "embed")
# In-body image uploads (rich-text editor). Extension allowlist plus a
# magic-byte sniff so a renamed file can't slip a non-image through. SVG is
# deliberately excluded (it can carry script). 8 MB cap.
ALLOWED_IMAGE_EXT = {"png", "jpg", "jpeg", "gif", "webp"}
MAX_UPLOAD_BYTES = 8 * 1024 * 1024
def _sniff_image(head):
"""Return a canonical extension if `head` (first bytes of a file) looks like
a supported image, else None."""
if head[:8] == b"\x89PNG\r\n\x1a\n":
return "png"
if head[:3] == b"\xff\xd8\xff":
return "jpg"
if head[:6] in (b"GIF87a", b"GIF89a"):
return "gif"
if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
return "webp"
return None
@admin_bp.context_processor
def inject_pending_demos():
"""Badge count on the nav. Only queried for a signed-in admin, so the login
page never touches the database."""
if not session.get("admin"):
return {"pending_demos": 0}
try:
return {"pending_demos": DemoRequest.query.filter(
DemoRequest.status == "new").count()}
except Exception: # noqa: BLE001 - a missing table must not 500 the panel
return {"pending_demos": 0}
# ------------------------------------------------------------------ auth
def login_required(view):
@wraps(view)
def wrapped(*args, **kwargs):
if not session.get("admin"):
return redirect(url_for("admin.login", next=request.path))
return view(*args, **kwargs)
return wrapped
@admin_bp.route("/login", methods=["GET", "POST"])
def login():
if session.get("admin"):
return redirect(url_for("admin.dashboard"))
if request.method == "POST":
username = request.form.get("username", "")
password = request.form.get("password", "")
cfg = current_app.config
expected_user = cfg.get("ADMIN_USERNAME", "")
pw_hash = cfg.get("ADMIN_PASSWORD_HASH", "")
user_ok = hmac.compare_digest(username, expected_user)
pass_ok = bool(pw_hash) and check_password_hash(pw_hash, password)
# Sanitize the attacker-controlled username before logging: collapse all
# whitespace (kills CR/LF log-injection) and cap length. The real client
# IP is logged LAST so a crafted username can't spoof the '... from <ip>'
# token the fail2ban filter anchors on at end-of-line.
safe_user = re.sub(r"\s+", " ", username).strip()[:64] or "-"
client_ip = request.remote_addr or "-"
if user_ok and pass_ok:
session.clear()
session["admin"] = username
auth_log.info("LOGIN OK user=%s from %s", safe_user, client_ip)
dest = request.args.get("next", "")
# only allow local admin redirects
if not dest.startswith("/admin"):
dest = url_for("admin.dashboard")
return redirect(dest)
auth_log.warning("FAILED LOGIN user=%s from %s", safe_user, client_ip)
flash("Incorrect username or password.", "error")
return render_template("admin/login.html")
@admin_bp.route("/logout", methods=["POST"])
def logout():
session.clear()
flash("Signed out.", "ok")
return redirect(url_for("admin.login"))
# ------------------------------------------------------------------ helpers
def _slugify(value):
value = (value or "").strip().lower()
value = re.sub(r"[^\w\s-]", "", value)
value = re.sub(r"[\s_]+", "-", value).strip("-")
return value or "topic"
def _unique_slug(base, exclude_id=None):
slug = base
n = 2
while True:
q = Topic.query.filter_by(slug=slug)
if exclude_id is not None:
q = q.filter(Topic.id != exclude_id)
if not q.first():
return slug
slug = f"{base}-{n}"
n += 1
def _int(value, default=0):
try:
return int(value)
except (TypeError, ValueError):
return default
# ------------------------------------------------------------------ dashboard
@admin_bp.route("/")
@login_required
def dashboard():
sections = Section.query.order_by(Section.sort_order, Section.num).all()
return render_template("admin/dashboard.html", sections=sections)
# ------------------------------------------------------------------ demo requests
@admin_bp.route("/demos")
@login_required
def demos():
"""Appointment requests from the public /demo form, soonest first."""
page = _int(request.args.get("page"), 1)
if page < 1:
page = 1
status = request.args.get("status", "")
q = DemoRequest.query
if status in DEMO_STATUSES:
q = q.filter(DemoRequest.status == status)
elif status == "":
# Default view hides what's already dealt with.
q = q.filter(DemoRequest.status.in_(("new", "scheduled")))
pagination = (
q.order_by(DemoRequest.preferred_date.asc(),
DemoRequest.preferred_time.asc(), DemoRequest.id.asc())
.paginate(page=page, per_page=50, error_out=False)
)
new_count = DemoRequest.query.filter(DemoRequest.status == "new").count()
return render_template(
"admin/demos.html",
pagination=pagination,
requests=pagination.items,
status=status,
statuses=DEMO_STATUSES,
new_count=new_count,
)
@admin_bp.route("/demo/<int:req_id>/status", methods=["POST"])
@login_required
def demo_status(req_id):
req = DemoRequest.query.get_or_404(req_id)
new_status = request.form.get("status", "")
if new_status not in DEMO_STATUSES:
abort(400)
req.status = new_status
db.session.commit()
log_action(session.get("admin"), "update", "demo", req.id,
f"{new_status}: {req.name} · {req.when}")
flash(f"Request from {req.name} marked {new_status}.", "ok")
# Come back to the same filtered view (never trust Referer for a redirect).
back = request.form.get("back_status", "")
return redirect(url_for("admin.demos",
status=back if back in DEMO_STATUSES else ""))
@admin_bp.route("/demo/<int:req_id>/delete", methods=["POST"])
@login_required
def demo_delete(req_id):
req = DemoRequest.query.get_or_404(req_id)
name, rid, when = req.name, req.id, req.when
db.session.delete(req)
db.session.commit()
log_action(session.get("admin"), "delete", "demo", rid, f"{name} · {when}")
flash(f"Request from {name} deleted.", "ok")
return redirect(url_for("admin.demos"))
# ------------------------------------------------------------------ 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", "demo"):
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/<int:topic_id>", methods=["GET", "POST"])
@login_required
def topic_form(topic_id=None):
topic = Topic.query.get_or_404(topic_id) if topic_id else None
sections = Section.query.order_by(Section.sort_order, Section.num).all()
if request.method == "POST":
f = request.form
title = f.get("title", "").strip()
section_id = _int(f.get("section_id"))
if not title or not section_id:
flash("Title and section are required.", "error")
return render_template(
"admin/topic_form.html", topic=topic, sections=sections,
media_types=MEDIA_TYPES, form=f,
)
media_type = f.get("media_type", "none")
if media_type not in MEDIA_TYPES:
media_type = "none"
slug_input = f.get("slug", "").strip()
base_slug = _slugify(slug_input or title)
slug = _unique_slug(base_slug, exclude_id=topic.id if topic else None)
is_new = topic is None
if is_new:
topic = Topic()
topic.section_id = section_id
topic.slug = slug
topic.title = title
topic.body_html = sanitize_html(f.get("body_html", ""))
topic.link_url = f.get("link_url", "").strip() or None
topic.link_label = f.get("link_label", "").strip() or None
topic.media_type = media_type
topic.media_url = f.get("media_url", "").strip() or None
topic.media_caption = f.get("media_caption", "").strip() or None
topic.sort_order = _int(f.get("sort_order"), 0)
topic.is_published = f.get("is_published") == "1"
if is_new:
db.session.add(topic)
db.session.commit()
log_action(
session.get("admin"), "create" if is_new else "update",
"topic", topic.id, topic.title,
)
flash(f"Topic '{topic.title}' saved.", "ok")
return redirect(url_for("admin.dashboard"))
return render_template(
"admin/topic_form.html", topic=topic, sections=sections,
media_types=MEDIA_TYPES, form=None,
)
@admin_bp.route("/topic/<int:topic_id>/delete", methods=["POST"])
@login_required
def topic_delete(topic_id):
topic = Topic.query.get_or_404(topic_id)
title, tid = topic.title, topic.id
db.session.delete(topic)
db.session.commit()
log_action(session.get("admin"), "delete", "topic", tid, title)
flash(f"Topic '{title}' deleted.", "ok")
return redirect(url_for("admin.dashboard"))
@admin_bp.route("/topic/<int:topic_id>/toggle", methods=["POST"])
@login_required
def topic_toggle(topic_id):
topic = Topic.query.get_or_404(topic_id)
topic.is_published = not topic.is_published
db.session.commit()
state = "published" if topic.is_published else "unpublished (draft)"
log_action(session.get("admin"), "update", "topic", topic.id,
f"{state}: {topic.title}")
flash(f"'{topic.title}' is now {state}.", "ok")
return redirect(url_for("admin.dashboard"))
# ------------------------------------------------------------------ reorder
@admin_bp.route("/reorder", methods=["POST"])
@login_required
def reorder():
"""Persist drag-and-drop order. JSON body:
{"type":"topic","section_id":N,"order":[id,...]} reorder within a section
{"type":"section","order":[id,...]} reorder sections
Sort values are rewritten to 10,20,30,... in the given order."""
data = request.get_json(silent=True) or {}
kind = data.get("type")
order = data.get("order") or []
if kind not in ("topic", "section") or not isinstance(order, list):
abort(400)
ids = []
for v in order:
iv = _int(v, None)
if iv is None:
abort(400)
ids.append(iv)
if kind == "topic":
section_id = _int(data.get("section_id"), None)
if section_id is None:
abort(400)
# Only reorder topics that actually belong to this section.
rows = {t.id: t for t in Topic.query.filter(
Topic.section_id == section_id, Topic.id.in_(ids)
).all()}
for i, tid in enumerate(ids):
if tid in rows:
rows[tid].sort_order = (i + 1) * 10
detail = f"reordered {len(rows)} topics in section {section_id}"
entity = "topic"
else:
rows = {s.id: s for s in Section.query.filter(Section.id.in_(ids)).all()}
for i, sid in enumerate(ids):
if sid in rows:
rows[sid].sort_order = (i + 1) * 10
detail = f"reordered {len(rows)} sections"
entity = "section"
db.session.commit()
log_action(session.get("admin"), "update", entity, None, detail)
return jsonify(ok=True)
# ------------------------------------------------------------------ uploads
@admin_bp.route("/upload", methods=["POST"])
@login_required
def upload():
"""Store an image dropped/picked in the rich-text editor and return its
public URL as JSON: {"url": "/static/uploads/<name>"}. CSRF is enforced by
the global CSRFProtect via the X-CSRFToken header the editor sends."""
file = request.files.get("file")
if file is None or not file.filename:
return jsonify(error="No file provided."), 400
if request.content_length and request.content_length > MAX_UPLOAD_BYTES:
return jsonify(error="File too large (max 8 MB)."), 413
ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
if ext not in ALLOWED_IMAGE_EXT:
return jsonify(error="Unsupported file type."), 400
# Verify the bytes actually look like an image, not just the name.
head = file.stream.read(12)
file.stream.seek(0)
sniffed = _sniff_image(head)
if sniffed is None:
return jsonify(error="File is not a valid image."), 400
upload_dir = os.path.join(current_app.static_folder, "uploads")
os.makedirs(upload_dir, exist_ok=True)
name = f"{uuid.uuid4().hex}.{sniffed}"
file.save(os.path.join(upload_dir, name))
log_action(session.get("admin"), "create", "upload", None, name)
return jsonify(url=url_for("static", filename=f"uploads/{name}"))
# ------------------------------------------------------------------ sections
@admin_bp.route("/section/new", methods=["GET", "POST"])
@admin_bp.route("/section/<int:section_id>", methods=["GET", "POST"])
@login_required
def section_form(section_id=None):
section = Section.query.get_or_404(section_id) if section_id else None
if request.method == "POST":
f = request.form
title = f.get("title", "").strip()
num = _int(f.get("num"))
if not title or not num:
flash("Number and title are required.", "error")
return render_template("admin/section_form.html", section=section, form=f)
# enforce unique num
clash = Section.query.filter(Section.num == num)
if section:
clash = clash.filter(Section.id != section.id)
if clash.first():
flash(f"Section number {num} is already in use.", "error")
return render_template("admin/section_form.html", section=section, form=f)
is_new = section is None
if is_new:
section = Section()
section.num = num
section.title = title
section.subtitle = f.get("subtitle", "").strip() or None
section.sort_order = _int(f.get("sort_order"), num * 10)
if is_new:
db.session.add(section)
db.session.commit()
log_action(
session.get("admin"), "create" if is_new else "update",
"section", section.id, section.title,
)
flash(f"Section '{section.title}' saved.", "ok")
return redirect(url_for("admin.dashboard"))
return render_template("admin/section_form.html", section=section, form=None)
@admin_bp.route("/section/<int:section_id>/delete", methods=["POST"])
@login_required
def section_delete(section_id):
section = Section.query.get_or_404(section_id)
title, sid = section.title, section.id
db.session.delete(section) # cascades to its topics
db.session.commit()
log_action(session.get("admin"), "delete", "section", sid, title)
flash(f"Section '{title}' and its topics deleted.", "ok")
return redirect(url_for("admin.dashboard"))