460 lines
16 KiB
Python
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"))
|