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
+14
View File
@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(curl -sSL -o /tmp/qtest.txt -w \"quill2: %{http_code} size:%{size_download}\\\\n\" https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.min.js)",
"Bash(curl -sSL -o quill2.min.js -w \"js: %{http_code} %{size_download}\\\\n\" https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.min.js)",
"Bash(curl -sSL -o quill2.snow.css -w \"css: %{http_code} %{size_download}\\\\n\" https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css)",
"Bash(mv -f quill2.min.js quill.min.js)",
"Bash(mv -f quill2.snow.css quill.snow.css)",
"Bash(mkdir -p static/uploads)",
"Bash(touch static/uploads/.gitkeep)",
"Bash(py --version)"
]
}
}
+2
View File
@@ -6,4 +6,6 @@ instance/
*.db
static/img/
static/vid/
static/uploads/*
!static/uploads/.gitkeep
logs/
+23 -10
View File
@@ -106,16 +106,28 @@ shows only `is_published` topics (sections with no published topics are hidden).
## Content editing (rich text / publish / reorder)
- **Rich text:** Quill 1.3.7 vendored at `static/vendor/` (no CDN — survives a
locked-down server or strict CSP). It's a *progressive enhancement over a real
`<textarea name="body_html" id="body_src">`*: `body.quill-on` is added only
after `new Quill()` succeeds (hides the textarea, shows the editor). If Quill
fails to load, the textarea stays usable and a save never wipes the body.
- **Rich text:** Quill 2.0.3 vendored at `static/vendor/quill.min.js` +
`quill.snow.css` (no CDN — survives a locked-down server or strict CSP). It's a
*progressive enhancement over a real `<textarea name="body_html" id="body_src">`*:
`body.quill-on` is added only after `new Quill()` succeeds (hides the textarea,
shows the editor). If Quill fails to load, the textarea stays usable and a save
never wipes the body. The submit handler keeps the body when it contains an
`img`/`table` even though `getText()` is empty for embed-only content.
- **Images & tables:** the custom `#editor-toolbar` has a `ql-image` button and a
`.ql-table-op` button group. Images upload via `POST /admin/upload`
(`login_required`, CSRF via `X-CSRFToken` header): extension allowlist +
magic-byte sniff (`_sniff_image`, SVG excluded), 8 MB cap, saved as a random
`uuid4().hex.<ext>` under `static/uploads/`, returns `{url}`; the handler
`insertEmbed`s it (no base64 → DB stays small). Tables use Quill 2's *built-in*
`table: true` module (`getModule('table')` → insertTable/insertRow…/deleteTable) —
no third-party plugin. Table buttons `preventDefault` on mousedown to keep the
cell selection; cell-dependent ops are wrapped in try/catch.
- **Sanitize on save:** `sanitize_html()` (bleach) runs on every `body_html`
write — allowlist `ALLOWED_TAGS`/`ALLOWED_ATTRS`, `strip=True`, and bleach
restricts link protocols to http/https/mailto (blocks `javascript:`). Empty /
`<p><br></p>` editor content is stored as NULL. The public page still renders
`body_html` via `Markup`, but the content is now sanitized at the source.
write — allowlist `ALLOWED_TAGS`/`ALLOWED_ATTRS` (now includes `img` + table
tags + `data-row`), `strip=True`; bleach restricts URL protocols on both `href`
and `img src` to http/https/mailto, blocking `javascript:` and `data:` (uploads
are relative `/static` paths). Empty / `<p><br></p>` editor content is stored as
NULL. The public page renders `body_html` via `Markup`, sanitized at the source.
- **Draft/publish:** `topic.is_published` (default 1, so existing rows stay
live). Public route filters to `Section.published_topics` and drops sections
with none. Admin: per-topic `/topic/<id>/toggle` (quick button) + a Published
@@ -139,7 +151,8 @@ sudo mysql < add_admin.sql # audit_log (existing DBs)
sudo mysql < add_publish.sql # topic.is_published (existing DBs)
# .env: SECRET_KEY, ADMIN_USERNAME, ADMIN_PASSWORD_HASH, SESSION_COOKIE_SECURE=1
sudo ./venv/bin/pip install -r requirements.txt # includes bleach
sudo systemctl restart jqc-features
sudo -u jqc mkdir -p static/uploads # in-body image uploads (writable)
sudo systemctl restart jqc-features # Quill 2 assets are static — no other step
# fail2ban:
sudo cp deploy/fail2ban/filter.d/jqc-admin.conf /etc/fail2ban/filter.d/
sudo cp deploy/fail2ban/jail.d/jqc-admin.local /etc/fail2ban/jail.d/
+11 -4
View File
@@ -183,13 +183,20 @@ Then visit `https://your-domain/admin`, sign in, and manage content.
change, newest first, filterable by action and type, paginated 50/page. Times
are UTC.
The editor (Quill) and drag library (SortableJS) are **vendored locally** under
The editor (Quill 2) and drag library (SortableJS) are **vendored locally** under
`static/vendor/` — no CDN dependency, so they work on a locked-down server and
survive a strict CSP. Rich-text HTML is sanitized on save (`bleach`) against a
tag allowlist, so a paste can't inject markup or `javascript:` links into the
public page. If the editor ever fails to load, the body field degrades to a
survive a strict CSP. The body toolbar supports **inline images** and **tables**
(Quill 2's built-in table module: insert, add/remove rows & columns). Images are
uploaded via `POST /admin/upload` — the file is stored under `static/uploads/`
and referenced by URL, so the database stays small (no base64). Rich-text HTML is
sanitized on save (`bleach`) against a tag allowlist that now includes `img` and
table tags, so a paste can't inject markup, `javascript:`, or `data:` URLs into
the public page. If the editor ever fails to load, the body field degrades to a
plain textarea — a save never wipes content.
`static/uploads/` must be writable by the app user (`jqc`) in production:
`sudo -u jqc mkdir -p static/uploads`. Uploaded files are gitignored.
### Notes
- `SESSION_COOKIE_SECURE=1` means the login cookie only sends over HTTPS. For a
+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"])
+17 -3
View File
@@ -91,14 +91,28 @@ def log_action(actor, action, entity, entity_id=None, detail=None):
ALLOWED_TAGS = [
"p", "br", "strong", "b", "em", "i", "u", "s", "strike",
"ul", "ol", "li", "a", "h2", "h3", "blockquote",
# images (inserted via the /admin/upload endpoint or a URL)
"img",
# tables (Quill 2 built-in table module)
"table", "thead", "tbody", "tr", "td", "th", "col", "colgroup",
]
ALLOWED_ATTRS = {"a": ["href", "title", "target", "rel"]}
ALLOWED_ATTRS = {
"a": ["href", "title", "target", "rel"],
"img": ["src", "alt", "width", "height"],
# Quill 2 tags cells/rows with data-row; keep the standard span attrs too.
"table": ["class"],
"td": ["data-row", "colspan", "rowspan"],
"th": ["data-row", "colspan", "rowspan"],
"tr": ["data-row"],
"col": ["width"],
}
def sanitize_html(raw):
"""Clean editor HTML against the allowlist. Returns None for empty content
so blank bodies stay NULL. bleach also restricts link protocols to
http/https/mailto, blocking javascript: URLs."""
so blank bodies stay NULL. bleach also restricts URL protocols to
http/https/mailto for both links and images, blocking javascript: and
data: URLs (uploaded images are served from a relative /static path)."""
if not raw:
return None
cleaned = bleach.clean(
+14
View File
@@ -263,3 +263,17 @@ body.quill-on #body_src{display:none} /* hide raw textarea on
#editor-toolbar.ql-toolbar .ql-stroke{stroke:var(--ink-soft)}
#editor-toolbar.ql-toolbar button:hover .ql-stroke{stroke:var(--aqua-deep)}
#editor-toolbar.ql-toolbar button.ql-active .ql-stroke{stroke:var(--aqua-deep)}
/* Text-labelled table buttons (not SVG icons) need auto width. */
#editor-toolbar.ql-toolbar .ql-table-op{
width:auto;padding:0 6px;font-size:.72rem;font-weight:600;color:var(--ink-soft);line-height:1.6;
}
#editor-toolbar.ql-toolbar .ql-table-op[data-op="insert"]{font-size:1rem}
#editor-toolbar.ql-toolbar .ql-table-op:hover{color:var(--aqua-deep)}
/* Images and tables inside the editor mirror how the public page renders them. */
#editor .ql-editor img{max-width:100%;height:auto;border-radius:6px}
#editor .ql-editor table{border-collapse:collapse;width:100%;margin:.5rem 0}
#editor .ql-editor td,#editor .ql-editor th{
border:1px solid var(--hair);padding:.4rem .55rem;min-width:2rem;
}
+5
View File
@@ -151,6 +151,11 @@ body{
.prose p{margin:0 0 .8em}
.prose p:last-child{margin-bottom:0}
.prose strong{color:var(--ink);font-weight:600}
.prose img{max-width:100%;height:auto;border-radius:var(--radius);border:1px solid var(--hair);margin:.6em 0}
.prose table{border-collapse:collapse;width:100%;margin:1em 0;font-size:.95rem}
.prose td,.prose th{border:1px solid var(--hair);padding:.5rem .65rem;text-align:left;vertical-align:top}
.prose th{background:rgba(0,0,0,.03);color:var(--ink);font-weight:600}
.prose .table-wrap{overflow-x:auto}
.topic__link{
align-self:flex-start;display:inline-flex;align-items:center;gap:8px;
View File
+8 -7
View File
File diff suppressed because one or more lines are too long
+6 -941
View File
File diff suppressed because one or more lines are too long
+71 -3
View File
@@ -74,8 +74,17 @@
</span>
<span class="ql-formats">
<button class="ql-link"></button>
<button class="ql-image" title="Insert image"></button>
<button class="ql-clean"></button>
</span>
<span class="ql-formats ql-table-tools">
<button type="button" class="ql-table-op" data-op="insert" title="Insert table"></button>
<button type="button" class="ql-table-op" data-op="row" title="Add row below">+Row</button>
<button type="button" class="ql-table-op" data-op="col" title="Add column right">+Col</button>
<button type="button" class="ql-table-op" data-op="delrow" title="Delete row">Row</button>
<button type="button" class="ql-table-op" data-op="delcol" title="Delete column">Col</button>
<button type="button" class="ql-table-op" data-op="deltable" title="Delete table">✕Table</button>
</span>
</div>
<div id="editor"></div>
<!-- Real field. Quill enhances it; if Quill fails to load, this stays a
@@ -140,16 +149,75 @@
(function () {
if (typeof Quill === 'undefined') return; // textarea stays usable
var ta = document.getElementById('body_src');
var toolbar = document.getElementById('editor-toolbar');
var toolbarEl = document.getElementById('editor-toolbar');
var editorEl = document.getElementById('editor');
var quill = new Quill(editorEl, { theme: 'snow', modules: { toolbar: toolbar } });
var quill = new Quill(editorEl, {
theme: 'snow',
// `table: true` enables Quill 2's built-in table module.
modules: { toolbar: toolbarEl, table: true }
});
if (ta.value.trim()) quill.clipboard.dangerouslyPasteHTML(ta.value);
var csrf = document.querySelector('meta[name="csrf-token"]').content;
var tableModule = quill.getModule('table');
// --- Image button: upload the file, then embed the returned URL. This
// keeps the DB small (no base64) and the image is served as a static file.
quill.getModule('toolbar').addHandler('image', function () {
var input = document.createElement('input');
input.type = 'file';
input.accept = 'image/png,image/jpeg,image/gif,image/webp';
input.onchange = function () {
var file = input.files && input.files[0];
if (!file) return;
var fd = new FormData();
fd.append('file', file);
fetch('{{ url_for("admin.upload") }}', {
method: 'POST', headers: { 'X-CSRFToken': csrf }, body: fd
}).then(function (r) {
return r.json().then(function (j) { return { ok: r.ok, body: j }; });
}).then(function (res) {
if (!res.ok) { alert(res.body.error || 'Upload failed.'); return; }
var range = quill.getSelection(true);
quill.insertEmbed(range.index, 'image', res.body.url, 'user');
quill.setSelection(range.index + 1, 'silent');
}).catch(function () { alert('Upload failed.'); });
};
input.click();
});
// --- Table controls. Row/column ops act on the cell holding the cursor.
toolbarEl.querySelectorAll('.ql-table-op').forEach(function (btn) {
// Preventing the mousedown default keeps the editor's selection so the
// table module knows which cell the cursor is in.
btn.addEventListener('mousedown', function (e) { e.preventDefault(); });
btn.addEventListener('click', function () {
var op = btn.getAttribute('data-op');
try {
if (op === 'insert') {
if (!quill.getSelection()) quill.setSelection(quill.getLength() - 1, 0);
tableModule.insertTable(3, 3);
}
else if (op === 'row') tableModule.insertRowBelow();
else if (op === 'col') tableModule.insertColumnRight();
else if (op === 'delrow') tableModule.deleteRow();
else if (op === 'delcol') tableModule.deleteColumn();
else if (op === 'deltable') tableModule.deleteTable();
} catch (err) {
alert('Place the cursor inside a table cell first.');
}
});
});
// Switch the UI from textarea to Quill only once it's ready.
document.body.classList.add('quill-on');
ta.form.addEventListener('submit', function () {
ta.value = quill.getText().trim().length ? quill.root.innerHTML : '';
// getText() is empty for an image/table-only body, so also check for
// embeds before treating the editor as blank (which would wipe the save).
var hasText = quill.getText().trim().length > 0;
var hasEmbed = quill.root.querySelector('img, table');
ta.value = (hasText || hasEmbed) ? quill.root.innerHTML : '';
});
})();
</script>