Jul 24 - Update Rich-text editor to support uploading images and inserting table
This commit is contained in:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,6 @@ instance/
|
||||
*.db
|
||||
static/img/
|
||||
static/vid/
|
||||
static/uploads/*
|
||||
!static/uploads/.gitkeep
|
||||
logs/
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Vendored
+8
-7
File diff suppressed because one or more lines are too long
Vendored
+6
-941
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user