Fix some issues

This commit is contained in:
2026-03-26 13:18:09 -04:00
parent ae6a44199f
commit 169820240a
25 changed files with 319 additions and 38 deletions
+55 -10
View File
@@ -5,6 +5,7 @@ from functools import wraps
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, jsonify, current_app, send_from_directory
from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
import bleach
from app import db
from app.models import (User, Ticket, Comment, ActivityLog, KnowledgeBase,
KBAttachment, UserRole, TicketStatus)
@@ -13,6 +14,39 @@ from app.services.log_service import log_action
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
logger = logging.getLogger(__name__)
# ── KB body HTML sanitisation ─────────────────────────────────────────────────
# TinyMCE produces rich HTML which must be sanitised server-side before
# persistence to prevent stored XSS attacks. Only tags and attributes that
# are safe to render are whitelisted; everything else is stripped.
_KB_ALLOWED_TAGS = set(bleach.sanitizer.ALLOWED_TAGS) | {
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'pre', 'code', 'blockquote', 'hr', 'br',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'ul', 'ol', 'li', 'dl', 'dt', 'dd',
'img', 'figure', 'figcaption',
'div', 'span', 'section',
'strong', 'em', 'u', 's', 'sub', 'sup',
}
_KB_ALLOWED_ATTRS = {
'*' : ['class', 'id', 'style'],
'a' : ['href', 'title', 'target', 'rel'],
'img': ['src', 'alt', 'width', 'height', 'title'],
'td' : ['colspan', 'rowspan'],
'th' : ['colspan', 'rowspan'],
'col': ['span'],
}
def _sanitize_kb_body(raw_html):
"""Strip disallowed tags/attributes from a TinyMCE-produced HTML body."""
cleaned = bleach.clean(
raw_html or '',
tags = _KB_ALLOWED_TAGS,
attributes = _KB_ALLOWED_ATTRS,
strip = True,
)
logger.debug(f'[KB SANITIZE] input_len={len(raw_html or "")} output_len={len(cleaned)}')
return cleaned
def admin_required(f):
@wraps(f)
@@ -112,10 +146,10 @@ def create_user():
)
user.set_password(password)
db.session.add(user)
db.session.commit()
log_action(current_user.id, 'admin_user_create', 'user', user.id,
f'email={email} role={role}')
db.session.commit()
logger.info(f'[ADMIN USER CREATE] user_id={user.id} email={email} role={role} by admin_id={current_user.id}')
flash(f'User {full_name} ({email}) created successfully.', 'success')
return redirect(url_for('admin.users'))
@@ -139,9 +173,9 @@ def edit_user(user_id):
if new_pw:
user.set_password(new_pw)
logger.info(f'[ADMIN PASSWORD RESET] target_user_id={user.id} by admin_id={current_user.id}')
db.session.commit()
log_action(current_user.id, 'admin_user_edit', 'user', user.id,
f'role_change={old_role}->{user.role} active={user.is_active}')
db.session.commit()
logger.info(f'[ADMIN USER EDIT] user_id={user.id} by admin_id={current_user.id}')
flash('User updated.', 'success')
return redirect(url_for('admin.users'))
@@ -157,8 +191,8 @@ def delete_user(user_id):
flash('You cannot delete your own account.', 'danger')
return redirect(url_for('admin.users'))
user.is_active = False
db.session.commit()
log_action(current_user.id, 'admin_user_deactivate', 'user', user.id)
db.session.commit()
logger.info(f'[ADMIN USER DEACTIVATE] user_id={user.id} by admin_id={current_user.id}')
flash('User deactivated.', 'success')
return redirect(url_for('admin.users'))
@@ -316,7 +350,7 @@ def kb_new():
try:
article = KnowledgeBase(
title = request.form.get('title', '').strip(),
body = request.form.get('body', '').strip(),
body = _sanitize_kb_body(request.form.get('body', '')),
category = request.form.get('category', ''),
tags = request.form.get('tags', ''),
author_id = current_user.id,
@@ -330,9 +364,9 @@ def kb_new():
att = _save_kb_file(f, article.id)
db.session.add(att)
db.session.commit()
log_action(current_user.id, 'kb_create', 'knowledge_base', article.id,
f'title={article.title}')
db.session.commit()
logger.info(f'[KB CREATE] article_id={article.id} by user_id={current_user.id}')
flash('Article created successfully.', 'success')
return jsonify({'ok': True, 'article_id': article.id, 'redirect': url_for('admin.kb_list')})
@@ -353,7 +387,7 @@ def kb_edit(article_id):
if request.method == 'POST':
try:
article.title = request.form.get('title', article.title).strip()
article.body = request.form.get('body', article.body).strip()
article.body = _sanitize_kb_body(request.form.get('body', article.body))
article.category = request.form.get('category', article.category)
article.tags = request.form.get('tags', article.tags)
article.is_published= bool(request.form.get('is_published')) and not bool(request.form.get('_save_as_draft'))
@@ -363,8 +397,8 @@ def kb_edit(article_id):
att = _save_kb_file(f, article.id)
db.session.add(att)
db.session.commit()
log_action(current_user.id, 'kb_edit', 'knowledge_base', article.id)
db.session.commit()
logger.info(f'[KB EDIT] article_id={article.id} by user_id={current_user.id}')
flash('Article updated successfully.', 'success')
return jsonify({'ok': True, 'article_id': article.id, 'redirect': url_for('admin.kb_list')})
@@ -382,10 +416,10 @@ def kb_toggle_publish(article_id):
"""Quick publish/unpublish toggle — callable from the article list."""
article = KnowledgeBase.query.get_or_404(article_id)
article.is_published = not article.is_published
db.session.commit()
state = 'published' if article.is_published else 'unpublished'
log_action(current_user.id, f'kb_{state}', 'knowledge_base', article.id,
f'title={article.title}')
db.session.commit()
logger.info(f'[KB TOGGLE PUBLISH] article_id={article.id} is_published={article.is_published} by user_id={current_user.id}')
flash(f'Article "{article.title}" has been {state}.', 'success')
return redirect(url_for('admin.kb_list'))
@@ -395,7 +429,18 @@ def kb_toggle_publish(article_id):
@login_required
@it_required
def kb_delete(article_id):
article = KnowledgeBase.query.get_or_404(article_id)
article = KnowledgeBase.query.get_or_404(article_id)
upload_dir = current_app.config['UPLOAD_FOLDER']
# Remove physical files before the cascade deletes the KBAttachment rows.
# Without this step the DB records disappear but the files remain on disk
# with no pointer to them — unrecoverable orphans.
for att in article.attachments.all():
filepath = os.path.join(upload_dir, att.stored_name)
if os.path.exists(filepath):
os.remove(filepath)
logger.info(f'[KB DELETE FILE] stored_name={att.stored_name} article_id={article_id} by user_id={current_user.id}')
log_action(current_user.id, 'kb_delete', 'knowledge_base', article.id,
f'title={article.title}')
logger.info(f'[KB DELETE] article_id={article.id} by user_id={current_user.id}')