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 *.db
static/img/ static/img/
static/vid/ static/vid/
static/uploads/*
!static/uploads/.gitkeep
logs/ 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) ## Content editing (rich text / publish / reorder)
- **Rich text:** Quill 1.3.7 vendored at `static/vendor/` (no CDN — survives a - **Rich text:** Quill 2.0.3 vendored at `static/vendor/quill.min.js` +
locked-down server or strict CSP). It's a *progressive enhancement over a real `quill.snow.css` (no CDN — survives a locked-down server or strict CSP). It's a
`<textarea name="body_html" id="body_src">`*: `body.quill-on` is added only *progressive enhancement over a real `<textarea name="body_html" id="body_src">`*:
after `new Quill()` succeeds (hides the textarea, shows the editor). If Quill `body.quill-on` is added only after `new Quill()` succeeds (hides the textarea,
fails to load, the textarea stays usable and a save never wipes the body. 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` - **Sanitize on save:** `sanitize_html()` (bleach) runs on every `body_html`
write — allowlist `ALLOWED_TAGS`/`ALLOWED_ATTRS`, `strip=True`, and bleach write — allowlist `ALLOWED_TAGS`/`ALLOWED_ATTRS` (now includes `img` + table
restricts link protocols to http/https/mailto (blocks `javascript:`). Empty / tags + `data-row`), `strip=True`; bleach restricts URL protocols on both `href`
`<p><br></p>` editor content is stored as NULL. The public page still renders and `img src` to http/https/mailto, blocking `javascript:` and `data:` (uploads
`body_html` via `Markup`, but the content is now sanitized at the source. 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 - **Draft/publish:** `topic.is_published` (default 1, so existing rows stay
live). Public route filters to `Section.published_topics` and drops sections live). Public route filters to `Section.published_topics` and drops sections
with none. Admin: per-topic `/topic/<id>/toggle` (quick button) + a Published 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) sudo mysql < add_publish.sql # topic.is_published (existing DBs)
# .env: SECRET_KEY, ADMIN_USERNAME, ADMIN_PASSWORD_HASH, SESSION_COOKIE_SECURE=1 # .env: SECRET_KEY, ADMIN_USERNAME, ADMIN_PASSWORD_HASH, SESSION_COOKIE_SECURE=1
sudo ./venv/bin/pip install -r requirements.txt # includes bleach 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: # fail2ban:
sudo cp deploy/fail2ban/filter.d/jqc-admin.conf /etc/fail2ban/filter.d/ 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/ 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 change, newest first, filterable by action and type, paginated 50/page. Times
are UTC. 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 `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 survive a strict CSP. The body toolbar supports **inline images** and **tables**
tag allowlist, so a paste can't inject markup or `javascript:` links into the (Quill 2's built-in table module: insert, add/remove rows & columns). Images are
public page. If the editor ever fails to load, the body field degrades to a 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. 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 ### Notes
- `SESSION_COOKIE_SECURE=1` means the login cookie only sends over HTTPS. For a - `SESSION_COOKIE_SECURE=1` means the login cookie only sends over HTTPS. For a
+56
View File
@@ -1,6 +1,8 @@
import hmac import hmac
import logging import logging
import os
import re import re
import uuid
from functools import wraps from functools import wraps
from flask import ( from flask import (
@@ -17,6 +19,26 @@ auth_log = logging.getLogger("jqc.auth")
MEDIA_TYPES = ("none", "image", "video", "embed") 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 # ------------------------------------------------------------------ auth
def login_required(view): def login_required(view):
@@ -268,6 +290,40 @@ def reorder():
return jsonify(ok=True) 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 # ------------------------------------------------------------------ sections
@admin_bp.route("/section/new", methods=["GET", "POST"]) @admin_bp.route("/section/new", methods=["GET", "POST"])
@admin_bp.route("/section/<int:section_id>", 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 = [ ALLOWED_TAGS = [
"p", "br", "strong", "b", "em", "i", "u", "s", "strike", "p", "br", "strong", "b", "em", "i", "u", "s", "strike",
"ul", "ol", "li", "a", "h2", "h3", "blockquote", "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): def sanitize_html(raw):
"""Clean editor HTML against the allowlist. Returns None for empty content """Clean editor HTML against the allowlist. Returns None for empty content
so blank bodies stay NULL. bleach also restricts link protocols to so blank bodies stay NULL. bleach also restricts URL protocols to
http/https/mailto, blocking javascript: URLs.""" 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: if not raw:
return None return None
cleaned = bleach.clean( 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 .ql-stroke{stroke:var(--ink-soft)}
#editor-toolbar.ql-toolbar button:hover .ql-stroke{stroke:var(--aqua-deep)} #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)} #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{margin:0 0 .8em}
.prose p:last-child{margin-bottom:0} .prose p:last-child{margin-bottom:0}
.prose strong{color:var(--ink);font-weight:600} .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{ .topic__link{
align-self:flex-start;display:inline-flex;align-items:center;gap:8px; 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>
<span class="ql-formats"> <span class="ql-formats">
<button class="ql-link"></button> <button class="ql-link"></button>
<button class="ql-image" title="Insert image"></button>
<button class="ql-clean"></button> <button class="ql-clean"></button>
</span> </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>
<div id="editor"></div> <div id="editor"></div>
<!-- Real field. Quill enhances it; if Quill fails to load, this stays a <!-- Real field. Quill enhances it; if Quill fails to load, this stays a
@@ -140,16 +149,75 @@
(function () { (function () {
if (typeof Quill === 'undefined') return; // textarea stays usable if (typeof Quill === 'undefined') return; // textarea stays usable
var ta = document.getElementById('body_src'); var ta = document.getElementById('body_src');
var toolbar = document.getElementById('editor-toolbar'); var toolbarEl = document.getElementById('editor-toolbar');
var editorEl = document.getElementById('editor'); 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); 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. // Switch the UI from textarea to Quill only once it's ready.
document.body.classList.add('quill-on'); document.body.classList.add('quill-on');
ta.form.addEventListener('submit', function () { 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> </script>