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
+7 -2
View File
@@ -11,9 +11,12 @@ DB_USER=it_ticket
DB_PASSWORD=IT.t1ck3t.5ys
# Mail Configuration (SMTP)
# Port 465 uses implicit SSL — MAIL_USE_SSL must be True, MAIL_USE_TLS must be False.
# If your SMTP server uses port 587, swap these: MAIL_USE_TLS=True, MAIL_USE_SSL=False.
MAIL_SERVER=mail.ltservicesinc.com
MAIL_PORT=465
MAIL_USE_TLS=True
MAIL_USE_TLS=False
MAIL_USE_SSL=True
MAIL_USERNAME=jqc.noreply@ltservicesinc.com
MAIL_PASSWORD=jQc.4utoMail$
MAIL_DEFAULT_SENDER=IT Helpdesk <jqc.noreply@ltservicesinc.com>
@@ -28,7 +31,9 @@ APP_BASE_URL=https://tickets.ltservicesinc.com
ANTHROPIC_API_KEY=your-anthropic-api-key-here
# File Upload Configuration
UPLOAD_FOLDER=app/static/uploads
# Must be an absolute path so all gunicorn workers resolve the same directory
# regardless of their working directory. Adjust to match your deployment path.
UPLOAD_FOLDER=/home/it-ticket/myapp/app/static/uploads
MAX_CONTENT_LENGTH=16777216
# Admin default credentials (change after first login)
+25 -1
View File
@@ -8,6 +8,9 @@ from flask_login import LoginManager
from flask_mail import Mail
from flask_migrate import Migrate
from flask_socketio import SocketIO
from flask_wtf.csrf import CSRFProtect
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from config.config import config
db = SQLAlchemy()
@@ -15,6 +18,13 @@ login_manager= LoginManager()
mail = Mail()
migrate = Migrate()
socketio = SocketIO()
csrf = CSRFProtect()
# limiter is a module-level name so blueprints can do `from app import limiter`,
# but the actual Limiter object is constructed inside create_app() — AFTER
# gunicorn has called eventlet.monkey_patch() in run.py. Constructing it here
# (at import time) creates a threading.RLock before the patch runs, which
# triggers: "1 RLock(s) were not greened".
limiter: Limiter = None # type: ignore[assignment]
def create_app(config_name=None):
@@ -32,10 +42,24 @@ def create_app(config_name=None):
login_manager.init_app(app)
mail.init_app(app)
migrate.init_app(app, db)
csrf.init_app(app)
# Limiter is constructed here — NOT at module level — so that
# eventlet.monkey_patch() (called in run.py before any imports) has already
# replaced threading.RLock with a green-thread-safe version. Constructing
# Limiter at module import time creates a real OS RLock before the patch
# runs, which produces: "1 RLock(s) were not greened".
global limiter
limiter = Limiter(
key_func = get_remote_address,
default_limits = [],
storage_uri = app.config.get('RATELIMIT_STORAGE_URI'),
)
limiter.init_app(app)
socketio.init_app(
app,
async_mode = 'eventlet',
cors_allowed_origins = '*',
cors_allowed_origins = app.config.get('APP_BASE_URL', ''),
# Ping settings: server sends a ping every 25s, client has 60s to respond.
# This ensures dead connections are detected and closed cleanly rather than
# being torn down by nginx timeouts, which causes [Errno 9] Bad file descriptor.
+2 -2
View File
@@ -218,8 +218,8 @@ class TicketHistory(db.Model):
ticket_id = db.Column(db.Integer, db.ForeignKey('tickets.id'), nullable=False)
changed_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
field_name = db.Column(db.String(50), nullable=False)
old_value = db.Column(db.String(200))
new_value = db.Column(db.String(200))
old_value = db.Column(db.Text)
new_value = db.Column(db.Text)
changed_at = db.Column(db.DateTime, default=datetime.utcnow)
changer = db.relationship('User', foreign_keys=[changed_by])
+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}')
+25 -4
View File
@@ -1,8 +1,9 @@
import logging
from datetime import datetime
from urllib.parse import urlparse, urljoin
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required, current_user
from app import db
from app import db, limiter
from app.models import User, UserRole
from app.services.log_service import log_action
@@ -10,7 +11,23 @@ auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
logger = logging.getLogger(__name__)
def _is_safe_url(target):
"""Return True only when *target* points back to this same host.
Prevents open-redirect attacks where an attacker crafts a login URL
with ?next=https://evil.com without this check the user would be
silently forwarded to an external site after authentication.
"""
ref_url = urlparse(request.host_url)
test_url = urlparse(urljoin(request.host_url, target))
return (
test_url.scheme in ('http', 'https') and
ref_url.netloc == test_url.netloc
)
@auth_bp.route('/login', methods=['GET', 'POST'])
@limiter.limit('10 per minute; 50 per hour')
def login():
if current_user.is_authenticated:
return redirect(url_for('tickets.dashboard'))
@@ -24,10 +41,13 @@ def login():
if user and user.check_password(password) and user.is_active:
login_user(user, remember=remember)
user.last_login = datetime.utcnow()
db.session.commit()
log_action(user.id, 'user_login', 'user', user.id, f'email={email}')
db.session.commit()
logger.info(f'[AUTH LOGIN] user_id={user.id} email={email}')
next_page = request.args.get('next')
if next_page and not _is_safe_url(next_page):
logger.warning(f'[AUTH OPEN-REDIRECT BLOCKED] next={next_page} user_id={user.id}')
next_page = None
return redirect(next_page or url_for('tickets.dashboard'))
else:
logger.warning(f'[AUTH FAILED] email={email} ip={request.remote_addr}')
@@ -37,6 +57,7 @@ def login():
@auth_bp.route('/register', methods=['GET', 'POST'])
@limiter.limit('5 per minute; 20 per hour')
def register():
if current_user.is_authenticated:
return redirect(url_for('tickets.dashboard'))
@@ -69,8 +90,8 @@ def register():
)
user.set_password(password)
db.session.add(user)
db.session.commit()
log_action(user.id, 'user_register', 'user', user.id, f'email={email}')
db.session.commit()
logger.info(f'[AUTH REGISTER] user_id={user.id} email={email}')
flash('Account created! You may now log in.', 'success')
return redirect(url_for('auth.login'))
@@ -116,8 +137,8 @@ def profile():
current_user.set_password(new_pw)
logger.info(f'[AUTH PASSWORD CHANGE] user_id={current_user.id}')
db.session.commit()
log_action(current_user.id, 'user_profile_update', 'user', current_user.id)
db.session.commit()
logger.info(f'[AUTH PROFILE UPDATE] user_id={current_user.id}')
flash('Profile updated successfully.', 'success')
+25 -4
View File
@@ -76,11 +76,33 @@ def chat():
end = reply_text.rfind('}') + 1
parsed = json.loads(reply_text[start:end])
if parsed.get('action') == 'create_ticket':
# Validate AI-provided enum values against allowed sets to
# prevent arbitrary strings reaching the database.
_valid_categories = {
TicketCategory.HARDWARE, TicketCategory.SOFTWARE,
TicketCategory.NETWORK, TicketCategory.ACCESS,
TicketCategory.EMAIL, TicketCategory.PRINTER,
TicketCategory.PHONE, TicketCategory.SECURITY,
TicketCategory.OTHER,
}
_valid_priorities = {
TicketPriority.LOW, TicketPriority.MEDIUM,
TicketPriority.HIGH, TicketPriority.CRITICAL,
}
raw_category = parsed.get('category', TicketCategory.OTHER)
raw_priority = parsed.get('priority', TicketPriority.MEDIUM)
safe_category = raw_category if raw_category in _valid_categories else TicketCategory.OTHER
safe_priority = raw_priority if raw_priority in _valid_priorities else TicketPriority.MEDIUM
if raw_category != safe_category:
logger.warning(f'[CHATBOT VALIDATION] invalid category="{raw_category}" coerced to "{safe_category}"')
if raw_priority != safe_priority:
logger.warning(f'[CHATBOT VALIDATION] invalid priority="{raw_priority}" coerced to "{safe_priority}"')
ticket = Ticket(
title = parsed.get('title', 'Untitled Issue'),
description = parsed.get('description', ''),
category = parsed.get('category', TicketCategory.OTHER),
priority = parsed.get('priority', TicketPriority.MEDIUM),
category = safe_category,
priority = safe_priority,
location = parsed.get('location', ''),
asset_tag = parsed.get('asset_tag', ''),
created_by_id = current_user.id,
@@ -89,10 +111,9 @@ def chat():
)
ticket.ticket_number = ticket.generate_ticket_number()
db.session.add(ticket)
db.session.commit()
log_action(current_user.id, 'ticket_create_chatbot', 'ticket', ticket.id,
f'ticket_number={ticket.ticket_number} ai_generated=True')
db.session.commit()
logger.info(f'[CHATBOT TICKET CREATE] ticket_id={ticket.id} number={ticket.ticket_number} user_id={current_user.id}')
notify_new_ticket(ticket)
+3 -3
View File
@@ -117,9 +117,9 @@ def create_ticket():
if f and f.filename and allowed_file(f.filename):
save_attachment(f, ticket_id=ticket.id, uploader_id=current_user.id)
db.session.commit()
log_action(current_user.id, 'ticket_create', 'ticket', ticket.id,
f'ticket_number={ticket.ticket_number} priority={priority} category={category}')
db.session.commit()
logger.info(f'[TICKET CREATE] ticket_id={ticket.id} number={ticket.ticket_number} by user_id={current_user.id}')
notify_new_ticket(ticket)
flash(f'Ticket {ticket.ticket_number} created successfully!', 'success')
@@ -197,9 +197,9 @@ def ticket_detail(ticket_id):
save_attachment(f, ticket_id=ticket.id,
comment_id=comment.id, uploader_id=current_user.id)
db.session.commit()
log_action(current_user.id, 'comment_create', 'comment', comment.id,
f'ticket_id={ticket.id} internal={is_internal}')
db.session.commit()
logger.info(f'[COMMENT CREATE] comment_id={comment.id} ticket_id={ticket.id} by user_id={current_user.id}')
notify_comment_added(comment)
flash('Comment added.', 'success')
@@ -275,9 +275,9 @@ def update_ticket(ticket_id):
except ValueError:
pass
db.session.commit()
log_action(current_user.id, 'ticket_update', 'ticket', ticket.id,
f'changes=[{"; ".join(changes)}]')
db.session.commit()
logger.info(f'[TICKET UPDATE] ticket_id={ticket.id} changes={changes} by user_id={current_user.id}')
if new_status != old_status:
+18 -3
View File
@@ -46,6 +46,14 @@ def log_action(user_id, action, entity_type=None, entity_id=None, details=None):
entity_type : str | None 'ticket', 'comment', 'user', etc.
entity_id : int | None primary key of the affected entity
details : str | None free-form JSON or human-readable details
Transaction note
----------------
This function deliberately does NOT call db.session.commit(). The entry
is added to the current session and committed by the caller alongside its
own business objects. This ensures the log entry is only persisted when
the parent operation succeeds an independent commit here would leave
orphaned log entries for operations that were subsequently rolled back.
"""
ip = _get_real_ip()
@@ -59,7 +67,8 @@ def log_action(user_id, action, entity_type=None, entity_id=None, details=None):
ip_address = ip,
)
db.session.add(entry)
db.session.commit()
# flush to surface constraint violations early without committing
db.session.flush()
logger.info(
f'[ACTIVITY] action={action} entity={entity_type}:{entity_id} '
f'user_id={user_id} ip={ip}'
@@ -70,7 +79,13 @@ def log_action(user_id, action, entity_type=None, entity_id=None, details=None):
def log_ticket_history(ticket, field_name, old_value, new_value, changed_by_id):
"""Record a granular field-level change on a ticket."""
"""Record a granular field-level change on a ticket.
Transaction note
----------------
Like log_action, this function does NOT commit the caller is responsible
for committing the session after all field changes have been recorded.
"""
from app.models import TicketHistory
try:
entry = TicketHistory(
@@ -81,7 +96,7 @@ def log_ticket_history(ticket, field_name, old_value, new_value, changed_by_id):
new_value = str(new_value) if new_value is not None else None,
)
db.session.add(entry)
db.session.commit()
db.session.flush()
logger.info(
f'[TICKET HISTORY] ticket_id={ticket.id} field={field_name} '
f'"{old_value}" -> "{new_value}" by user_id={changed_by_id}'
+1
View File
@@ -18,6 +18,7 @@
</div>
<div class="card-body">
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<!-- ── Identity ─────────────────────────────────────────────────── -->
<div style="font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:var(--muted);margin-bottom:14px;">
+1
View File
@@ -10,6 +10,7 @@
<div class="card-header"><i class="bi bi-person-gear me-2"></i>Edit: {{ user.full_name }}</div>
<div class="card-body">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Full Name</label>
+1
View File
@@ -43,6 +43,7 @@
<form method="POST" enctype="multipart/form-data" id="kb-form"
data-article-id="{{ article.id if article else '' }}"
data-atts-url="{{ url_for('admin.kb_get_attachments', article_id=article.id) if article else '' }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-3">
<label class="form-label">Title *</label>
<input type="text" class="form-control" name="title" required
+2
View File
@@ -37,6 +37,7 @@
<a href="{{ url_for('admin.kb_edit', article_id=art.id) }}" class="btn btn-secondary btn-sm" title="Edit"><i class="bi bi-pencil"></i></a>
<!-- Publish / Unpublish quick toggle -->
<form method="POST" action="{{ url_for('admin.kb_toggle_publish', article_id=art.id) }}" style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% if art.is_published %}
<button type="submit" class="btn btn-sm"
style="background:rgba(251,191,36,.1);border:1px solid rgba(251,191,36,.3);color:var(--warning);"
@@ -52,6 +53,7 @@
{% endif %}
</form>
<form method="POST" action="{{ url_for('admin.kb_delete', article_id=art.id) }}" onsubmit="return confirm('Delete this article?');" style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm" style="background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.2);color:var(--danger);" title="Delete"><i class="bi bi-trash"></i></button>
</form>
</div>
+1 -1
View File
@@ -61,7 +61,7 @@
{% if u.id != current_user.id and u.is_active %}
<form method="POST" action="{{ url_for('admin.delete_user', user_id=u.id) }}"
onsubmit="return confirm('Deactivate {{ u.full_name }}?');">
<button type="submit" class="btn btn-sm" style="background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.2);color:var(--danger);">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <button type="submit" class="btn btn-sm" style="background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.2);color:var(--danger);">
<i class="bi bi-person-dash"></i>
</button>
</form>
+1
View File
@@ -53,6 +53,7 @@
{% endwith %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label>Email Address</label>
<input type="email" name="email" required placeholder="you@company.com" autofocus/>
+1
View File
@@ -9,6 +9,7 @@
<div class="card-header"><i class="bi bi-person-circle me-2"></i>Account Settings</div>
<div class="card-body">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<!-- Avatar placeholder -->
<div class="text-center mb-4">
<div style="width:80px;height:80px;border-radius:50%;background:var(--accent2);display:flex;align-items:center;justify-content:center;font-size:32px;font-weight:700;color:#fff;margin:0 auto 10px;">
+1
View File
@@ -45,6 +45,7 @@
{% endwith %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="row">
<div class="form-group">
<label>Full Name *</label>
+25 -6
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<meta name="csrf-token" content="{{ csrf_token() }}"/>
<title>{% block title %}IT Helpdesk{% endblock %} — TechDesk</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
@@ -438,6 +439,27 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<script>
// ── CSRF helper ───────────────────────────────────────────────────────────────
// All state-changing fetch() calls must include the CSRF token header.
// Use csrfPost(url, body) instead of raw fetch(..., {method:'POST'}) to ensure
// the token from the <meta> tag is sent automatically.
const _csrfToken = () => document.querySelector('meta[name="csrf-token"]')?.content || '';
async function csrfPost(url, body = null) {
const opts = {
method : 'POST',
headers: { 'X-CSRFToken': _csrfToken() },
};
if (body !== null) {
if (typeof body === 'object' && !(body instanceof FormData)) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
} else {
opts.body = body;
}
}
return fetch(url, opts);
}
// ── WebSocket ────────────────────────────────────────────────────────────────
{% if current_user.is_authenticated %}
const socket = io({
@@ -506,7 +528,7 @@ function _bindNotifClick(el) {
const link = el.dataset.link;
// Mark as read in DB
if(el.classList.contains('unread')){
try { await fetch(`/api/notifications/${id}/read`, { method: 'POST' }); } catch(_){}
try { await csrfPost(`/api/notifications/${id}/read`); } catch(_){}
el.classList.remove('unread');
const dot = el.querySelector('.notif-dot');
if(dot) dot.remove();
@@ -567,7 +589,7 @@ function prependNotif(n){
}
async function markAllRead(){
try { await fetch('/api/notifications/mark-all-read', { method: 'POST' }); } catch(_){}
try { await csrfPost('/api/notifications/mark-all-read'); } catch(_){}
updateBadge(0);
document.querySelectorAll('.notif-item.unread').forEach(el => {
el.classList.remove('unread');
@@ -601,10 +623,7 @@ async function sendChat(){
chatHistory.push({role:'user',content:msg});
const typing = appendTyping();
try{
const r = await fetch('/chatbot/message',{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({message:msg,history:chatHistory})
});
const r = await csrfPost('/chatbot/message', {message:msg,history:chatHistory});
const d = await r.json();
typing.remove();
const reply = d.reply || 'Sorry, I encountered an error.';
+1
View File
@@ -11,6 +11,7 @@
</div>
<div class="card-body">
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="row g-3">
<div class="col-12">
<label class="form-label">Issue Title *</label>
+3
View File
@@ -88,6 +88,7 @@
{% if current_user.is_it_staff or comment.author_id == current_user.id %}
<form method="POST" action="{{ url_for('tickets.delete_comment', comment_id=comment.id) }}"
onsubmit="return confirm('Delete this comment?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm" style="background:none;border:none;color:var(--muted);padding:2px 6px;"
title="Delete comment">
<i class="bi bi-trash"></i>
@@ -122,6 +123,7 @@
<div class="card-header"><i class="bi bi-chat-plus me-2"></i>Add Comment</div>
<div class="card-body">
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-3">
<textarea class="form-control" name="body" rows="4" required
placeholder="Add your update, follow-up, or response here…"></textarea>
@@ -157,6 +159,7 @@
<div class="card-header"><i class="bi bi-pencil-square me-2"></i>Update Ticket</div>
<div class="card-body">
<form method="POST" action="{{ url_for('tickets.update_ticket', ticket_id=ticket.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-3">
<label class="form-label">Status</label>
<select class="form-select" name="status">
+1
View File
@@ -9,6 +9,7 @@
<div class="card-header d-flex align-items-center justify-content-between">
<span><i class="bi bi-bell me-2"></i>All Notifications</span>
<form method="POST" action="{{ url_for('tickets.mark_notifications_read') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-secondary btn-sm">
<i class="bi bi-check2-all me-1"></i>Mark All Read
</button>
+11
View File
@@ -22,6 +22,7 @@ class Config:
MAIL_SERVER = os.environ.get('MAIL_SERVER', 'localhost')
MAIL_PORT = int(os.environ.get('MAIL_PORT', 587))
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'True').lower() == 'true'
MAIL_USE_SSL = os.environ.get('MAIL_USE_SSL', 'False').lower() == 'true'
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER', 'IT Helpdesk <noreply@yourdomain.com>')
@@ -46,6 +47,16 @@ class Config:
# Anthropic AI
ANTHROPIC_API_KEY = os.environ.get('ANTHROPIC_API_KEY', '')
# Rate limiting storage.
# The `limits` library (used by Flask-Limiter) does not support MySQL as a
# storage backend — its only supported schemes are memory://, redis://, and
# memcached://. Since gunicorn.conf.py enforces a single worker process
# (workers = 1, worker_class = eventlet), in-process memory is both correct
# and sufficient — there is no other worker to share counters with, and a
# counter reset on restart is an acceptable trade-off versus adding Redis.
# To switch to Redis when it becomes available: set RATELIMIT_STORAGE_URI=redis://...
RATELIMIT_STORAGE_URI = os.environ.get('RATELIMIT_STORAGE_URI', 'memory://')
# Admin
ADMIN_EMAIL = os.environ.get('ADMIN_EMAIL', 'admin@yourdomain.com')
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', 'Admin@123!')
+15 -2
View File
@@ -12,8 +12,21 @@ worker_connections= 2000
timeout = 300
keepalive = 75
# Preloading ensures eventlet.monkey_patch() in run.py runs once before fork
preload_app = True
# preload_app must be False with the eventlet worker.
#
# With preload_app = True, gunicorn loads the WSGI app in the master process
# before forking workers. At that point gunicorn's own internals have already
# imported `logging` and other stdlib modules, creating real OS RLocks.
# The eventlet worker calls eventlet.monkey_patch() at its own import time —
# but that import happens *after* the master has already loaded the app, so
# the patch arrives too late and eventlet emits:
# "1 RLock(s) were not greened"
#
# With preload_app = False, workers are forked first. The eventlet worker
# module is imported inside each worker process, its module-level
# monkey_patch() fires before the app is loaded, and all subsequent imports
# (including logging) get the green versions from the start.
preload_app = False
# Application
wsgi_app = "run:app"
+31
View File
@@ -0,0 +1,31 @@
from logging.config import fileConfig
from flask import current_app
from alembic import context
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = current_app.extensions['migrate'].db.metadata
def run_migrations_offline():
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata,
literal_binds=True, dialect_opts={"paramstyle": "named"})
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
connectable = current_app.extensions['migrate'].db.engine
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -0,0 +1,61 @@
"""Widen ticket_history old_value / new_value from VARCHAR(200) to TEXT
Revision ID: 001_widen_ticket_history_values
Revises:
Create Date: 2026-03-26
Rationale
---------
TicketHistory.old_value and new_value were VARCHAR(200), which would cause
a DataError (string too long) or silent truncation if a long internal_notes
or resolution_notes value were ever stored in ticket history. TEXT has no
practical upper-bound and is the correct type for free-form field snapshots.
Apply
-----
flask db upgrade
Rollback
--------
flask db downgrade
"""
from alembic import op
import sqlalchemy as sa
revision = '001_widen_ticket_history_values'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table('ticket_history', schema=None) as batch_op:
batch_op.alter_column(
'old_value',
existing_type=sa.String(length=200),
type_=sa.Text(),
existing_nullable=True,
)
batch_op.alter_column(
'new_value',
existing_type=sa.String(length=200),
type_=sa.Text(),
existing_nullable=True,
)
def downgrade():
with op.batch_alter_table('ticket_history', schema=None) as batch_op:
batch_op.alter_column(
'new_value',
existing_type=sa.Text(),
type_=sa.String(length=200),
existing_nullable=True,
)
batch_op.alter_column(
'old_value',
existing_type=sa.Text(),
type_=sa.String(length=200),
existing_nullable=True,
)
+2
View File
@@ -17,3 +17,5 @@ email-validator==2.2.0
requests==2.32.3
APScheduler==3.10.4
marshmallow==3.21.3
bleach==6.1.0
Flask-Limiter==3.5.0