04/06 remediate some issues
This commit is contained in:
+16
-4
@@ -9,7 +9,7 @@ from flask import Blueprint, render_template, redirect, url_for, flash, request,
|
||||
from flask_login import login_required, current_user
|
||||
from werkzeug.utils import secure_filename
|
||||
import bleach
|
||||
from app import db
|
||||
from app import db, limiter
|
||||
from app.models import (User, Ticket, Comment, ActivityLog, KnowledgeBase,
|
||||
KBAttachment, UserRole, TicketStatus)
|
||||
from app.services.log_service import log_action
|
||||
@@ -404,8 +404,10 @@ def all_tickets():
|
||||
|
||||
if search:
|
||||
from app.models import Comment
|
||||
from sqlalchemy import func
|
||||
submitter_alias = db.aliased(User)
|
||||
assignee_alias = db.aliased(User)
|
||||
stripped_body = func.regexp_replace(Comment.body, r'<[^>]+>', '', 'g')
|
||||
q = (
|
||||
q
|
||||
.outerjoin(submitter_alias, submitter_alias.id == Ticket.created_by_id)
|
||||
@@ -417,7 +419,7 @@ def all_tickets():
|
||||
Ticket.description.ilike(f'%{search}%') |
|
||||
submitter_alias.full_name.ilike(f'%{search}%') |
|
||||
assignee_alias.full_name.ilike(f'%{search}%') |
|
||||
Comment.body.ilike(f'%{search}%')
|
||||
stripped_body.ilike(f'%{search}%')
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
@@ -540,6 +542,7 @@ def kb_upload_image():
|
||||
# ── File-serve route (images embedded in articles + attachment downloads) ─────
|
||||
|
||||
@admin_bp.route('/kb/files/<string:stored_name>')
|
||||
@login_required
|
||||
def kb_serve_file(stored_name):
|
||||
"""Serve a KB attachment file. Login required — no public access.
|
||||
|
||||
@@ -551,6 +554,12 @@ def kb_serve_file(stored_name):
|
||||
traverse outside the upload directory. <string:> disallows slashes,
|
||||
restricting the value to a flat filename — matching the UUID-based
|
||||
stored_name format (e.g. 'a1b2c3d4e5f6....png') used by all upload helpers.
|
||||
|
||||
@login_required is applied here because the upload directory is shared
|
||||
across KB files, ticket attachments, comment images, and user avatars.
|
||||
Without authentication, an unauthenticated caller who knows or guesses
|
||||
any stored_name (UUID-based) could retrieve arbitrary files from the
|
||||
shared uploads folder — including confidential ticket attachments.
|
||||
"""
|
||||
upload_dir = current_app.config['UPLOAD_FOLDER']
|
||||
return send_from_directory(upload_dir, stored_name)
|
||||
@@ -572,7 +581,6 @@ def kb_delete_attachment(article_id, att_id):
|
||||
logger.info(f'[KB ATTACHMENT DELETE] att_id={att.id} article_id={article_id} by user_id={current_user.id}')
|
||||
db.session.delete(att)
|
||||
db.session.commit()
|
||||
logger.info(f'[KB ATTACHMENT DELETE] att_id={att.id} article_id={article_id} completed')
|
||||
# Return JSON so the edit page can remove the row without a full reload
|
||||
return jsonify({'ok': True, 'att_id': att.id})
|
||||
|
||||
@@ -766,6 +774,7 @@ def activity_logs():
|
||||
@admin_bp.route('/tickets/export')
|
||||
@login_required
|
||||
@admin_required
|
||||
@limiter.limit('10 per hour')
|
||||
def export_tickets():
|
||||
"""Stream a CSV of tickets matching the current filter params."""
|
||||
status = request.args.get('status', '')
|
||||
@@ -780,8 +789,10 @@ def export_tickets():
|
||||
elif assigned == 'unassigned': q = q.filter_by(assigned_to_id=None)
|
||||
|
||||
if search:
|
||||
from sqlalchemy import func
|
||||
submitter_alias = db.aliased(User)
|
||||
assignee_alias = db.aliased(User)
|
||||
stripped_body = func.regexp_replace(Comment.body, r'<[^>]+>', '', 'g')
|
||||
q = (
|
||||
q
|
||||
.outerjoin(submitter_alias, submitter_alias.id == Ticket.created_by_id)
|
||||
@@ -793,7 +804,7 @@ def export_tickets():
|
||||
Ticket.description.ilike(f'%{search}%') |
|
||||
submitter_alias.full_name.ilike(f'%{search}%') |
|
||||
assignee_alias.full_name.ilike(f'%{search}%') |
|
||||
Comment.body.ilike(f'%{search}%')
|
||||
stripped_body.ilike(f'%{search}%')
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
@@ -845,6 +856,7 @@ def _roles():
|
||||
|
||||
|
||||
@admin_bp.route('/settings', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def settings():
|
||||
from app.models import SystemSetting
|
||||
|
||||
+28
-4
@@ -42,22 +42,46 @@ def _call_groq(api_key, history, user_msg):
|
||||
The system prompt is prepended as a system message. Prior history and the
|
||||
new user message are appended in order.
|
||||
|
||||
History is capped at the most recent _MAX_HISTORY_TURNS turns and each
|
||||
message content is truncated to _MAX_MSG_CHARS characters before being
|
||||
forwarded. This prevents a malicious or runaway client from exhausting
|
||||
the model's context window or inflating token costs.
|
||||
|
||||
Raises requests.HTTPError or requests.exceptions.RequestException on failure.
|
||||
"""
|
||||
# ── History sanitisation ──────────────────────────────────────────────────
|
||||
# 1. Strip create_ticket action blocks — re-sending them causes the model
|
||||
# to re-trigger ticket creation on every subsequent turn.
|
||||
# 2. Cap to the most recent N turns so the client cannot inflate context.
|
||||
# 3. Truncate each message's content to avoid per-message token blowout.
|
||||
_MAX_HISTORY_TURNS = 20
|
||||
_MAX_MSG_CHARS = 2000
|
||||
|
||||
model = current_app.config.get('GROQ_MODEL', _GROQ_MODEL)
|
||||
|
||||
# Strip any assistant messages containing a create_ticket action block.
|
||||
# These should never reach the model — if they do, the model re-triggers
|
||||
# ticket creation on every subsequent turn.
|
||||
clean_history = [
|
||||
msg for msg in history
|
||||
if not (msg.get('role') == 'assistant' and '"action": "create_ticket"' in msg.get('content', ''))
|
||||
]
|
||||
|
||||
# Keep only the most recent turns after filtering
|
||||
if len(clean_history) > _MAX_HISTORY_TURNS:
|
||||
logger.warning(
|
||||
f'[CHATBOT] history truncated from {len(clean_history)} to '
|
||||
f'{_MAX_HISTORY_TURNS} turns for user_id={current_user.id}'
|
||||
)
|
||||
clean_history = clean_history[-_MAX_HISTORY_TURNS:]
|
||||
|
||||
# Truncate individual message content lengths
|
||||
clean_history = [
|
||||
{**msg, 'content': msg.get('content', '')[:_MAX_MSG_CHARS]}
|
||||
for msg in clean_history
|
||||
]
|
||||
|
||||
messages = (
|
||||
[{'role': 'system', 'content': _SYSTEM_PROMPT}]
|
||||
+ clean_history
|
||||
+ [{'role': 'user', 'content': user_msg}]
|
||||
+ [{'role': 'user', 'content': user_msg[:_MAX_MSG_CHARS]}]
|
||||
)
|
||||
resp = requests.post(
|
||||
_GROQ_API_URL,
|
||||
|
||||
+47
-5
@@ -25,6 +25,23 @@ logger = logging.getLogger(__name__)
|
||||
ALLOWED_EXT = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'doc', 'docx', 'txt', 'zip', 'log'}
|
||||
|
||||
|
||||
def _strip_html(text: str) -> str:
|
||||
"""Remove HTML tags from *text* for plain-text search matching.
|
||||
|
||||
Comment bodies are stored as sanitized HTML (rendered at write time via
|
||||
render_comment_body). Searching with ilike('%term%') against raw HTML
|
||||
produces two problems:
|
||||
1. A search for 'bold' misses '<strong>bold</strong>' in the stored body.
|
||||
2. HTML tag names ('strong', 'pre') can accidentally match search terms.
|
||||
Stripping tags before comparison gives consistent, tag-agnostic results.
|
||||
|
||||
Uses a simple regex rather than a full HTML parser — sufficient for the
|
||||
sanitized subset of HTML that bleach allows in comment bodies.
|
||||
"""
|
||||
import re
|
||||
return re.sub(r'<[^>]+>', '', text)
|
||||
|
||||
|
||||
def _resolve_mime_type(att):
|
||||
"""Return a reliable MIME type for an attachment.
|
||||
|
||||
@@ -195,8 +212,13 @@ def ticket_list():
|
||||
if search:
|
||||
# Extend search to cover comments and assignee name via outer joins.
|
||||
# distinct() prevents duplicate ticket rows when multiple comments match.
|
||||
# Comment bodies are stored as sanitized HTML — use REGEXP_REPLACE to
|
||||
# strip tags at the SQL level before matching so 'bold' finds
|
||||
# '<strong>bold</strong>' and HTML tag names don't pollute results.
|
||||
from app.models import Comment
|
||||
from sqlalchemy import func
|
||||
assignee_alias = db.aliased(User)
|
||||
stripped_body = func.regexp_replace(Comment.body, r'<[^>]+>', '', 'g')
|
||||
query = (
|
||||
query
|
||||
.outerjoin(Comment, Comment.ticket_id == Ticket.id)
|
||||
@@ -205,7 +227,7 @@ def ticket_list():
|
||||
Ticket.title.ilike(f'%{search}%') |
|
||||
Ticket.ticket_number.ilike(f'%{search}%') |
|
||||
Ticket.description.ilike(f'%{search}%') |
|
||||
Comment.body.ilike(f'%{search}%') |
|
||||
stripped_body.ilike(f'%{search}%') |
|
||||
assignee_alias.full_name.ilike(f'%{search}%')
|
||||
)
|
||||
.distinct()
|
||||
@@ -389,12 +411,32 @@ def update_ticket(ticket_id):
|
||||
@tickets_bp.route('/comments/<int:comment_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_comment(comment_id):
|
||||
comment = db.session.get(Comment, comment_id) or abort(404)
|
||||
if not current_user.is_it_staff and comment.author_id != current_user.id:
|
||||
abort(403)
|
||||
comment = db.session.get(Comment, comment_id) or abort(404)
|
||||
ticket_id = comment.ticket_id
|
||||
|
||||
# Authorization: IT staff may always delete any comment.
|
||||
# Employees may only delete their own comments, and only while the
|
||||
# ticket is still open or in-progress. Allowing deletion on resolved
|
||||
# or closed tickets would silently alter the historical record of a
|
||||
# completed support interaction.
|
||||
if not current_user.is_it_staff:
|
||||
if comment.author_id != current_user.id:
|
||||
abort(403)
|
||||
ticket = db.session.get(Ticket, ticket_id) or abort(404)
|
||||
if ticket.status in (TicketStatus.RESOLVED, TicketStatus.CLOSED):
|
||||
logger.warning(
|
||||
f'[COMMENT DELETE BLOCKED] comment_id={comment.id} '
|
||||
f'ticket_id={ticket_id} status={ticket.status} '
|
||||
f'user_id={current_user.id} — ticket is {ticket.status}'
|
||||
)
|
||||
flash('Comments cannot be deleted on resolved or closed tickets.', 'warning')
|
||||
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
|
||||
|
||||
# Include a truncated snapshot of the body in the audit log so the
|
||||
# content is recoverable from logs even after the DB row is gone.
|
||||
body_snapshot = comment.body[:200].replace('\n', ' ')
|
||||
log_action(current_user.id, 'comment_delete', 'comment', comment.id,
|
||||
f'ticket_id={ticket_id}')
|
||||
f'ticket_id={ticket_id} body_snapshot="{body_snapshot}"')
|
||||
logger.info(f'[COMMENT DELETE] comment_id={comment.id} ticket_id={ticket_id} by user_id={current_user.id}')
|
||||
db.session.delete(comment)
|
||||
db.session.commit()
|
||||
|
||||
Reference in New Issue
Block a user