Jul 24 - Update Rich-text editor to support uploading images and inserting table

This commit is contained in:
2026-07-24 09:55:41 -04:00
parent 69b97d51fe
commit 10a8658b67
12 changed files with 227 additions and 968 deletions
+56
View File
@@ -1,6 +1,8 @@
import hmac
import logging
import os
import re
import uuid
from functools import wraps
from flask import (
@@ -17,6 +19,26 @@ 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):
@@ -268,6 +290,40 @@ def reorder():
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"])