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, 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 # ------------------------------------------------------------------ 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 ' # 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) # ------------------------------------------------------------------ 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"]) @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//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//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/"}. 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/", 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//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"))