This commit is contained in:
2026-03-31 10:55:40 -04:00
parent 702fc2cd91
commit d94268f2b9
4 changed files with 189 additions and 31 deletions
+43 -3
View File
@@ -25,6 +25,29 @@ logger = logging.getLogger(__name__)
ALLOWED_EXT = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'doc', 'docx', 'txt', 'zip', 'log'}
def _resolve_mime_type(att):
"""Return a reliable MIME type for an attachment.
Browsers sometimes send 'application/octet-stream' for images on upload,
and older attachments may have NULL mime_type. Fall back to an
extension-based lookup so images are always served inline correctly.
"""
stored = (att.mime_type or '').lower().strip()
if stored.startswith('image/'):
return stored
ext_map = {
'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
'gif': 'image/gif', 'webp': 'image/webp', 'svg': 'image/svg+xml',
'pdf': 'application/pdf',
'doc': 'application/msword',
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'txt': 'text/plain', 'log': 'text/plain',
'zip': 'application/zip',
}
ext = att.filename.rsplit('.', 1)[-1].lower() if '.' in att.filename else ''
return ext_map.get(ext, stored or 'application/octet-stream')
def save_attachment(file, ticket_id=None, comment_id=None, uploader_id=None):
filename = secure_filename(file.filename)
ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
@@ -42,6 +65,22 @@ def save_attachment(file, ticket_id=None, comment_id=None, uploader_id=None):
)
db.session.add(att)
return att
filename = secure_filename(file.filename)
ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
stored_name = f"{uuid.uuid4().hex}.{ext}"
upload_dir = current_app.config['UPLOAD_FOLDER']
file.save(os.path.join(upload_dir, stored_name))
att = Attachment(
ticket_id = ticket_id,
comment_id = comment_id,
filename = filename,
stored_name= stored_name,
file_size = os.path.getsize(os.path.join(upload_dir, stored_name)),
mime_type = file.content_type,
uploaded_by= uploader_id,
)
db.session.add(att)
return att
# ─── Dashboard ────────────────────────────────────────────────────────────────
@@ -382,13 +421,14 @@ def download_attachment(att_id):
)
abort(403)
upload_dir = current_app.config['UPLOAD_FOLDER']
is_image = (att.mime_type or '').startswith('image/')
mime = _resolve_mime_type(att)
is_image = mime.startswith('image/')
return send_from_directory(
upload_dir,
att.stored_name,
as_attachment = not is_image, # images render inline; other files force-download
as_attachment = not is_image,
download_name = att.filename,
mimetype = att.mime_type or None,
mimetype = mime,
)
+22 -11
View File
@@ -41,7 +41,9 @@
<div class="card-body">
<div class="d-flex flex-wrap gap-2 align-items-start">
{% for att in ticket_atts %}
{% if att.mime_type and att.mime_type.startswith('image/') %}
{% set ext = att.filename.rsplit('.', 1)[-1].lower() if '.' in att.filename else '' %}
{% set is_img = (att.mime_type and att.mime_type.startswith('image/')) or ext in ('png','jpg','jpeg','gif','webp','svg') %}
{% if is_img %}
<a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
target="_blank" class="comment-img-link" title="{{ att.filename }}">
<img src="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
@@ -124,7 +126,9 @@
{% if c_atts %}
<div class="mt-2">
{% for att in c_atts %}
{% if att.mime_type and att.mime_type.startswith('image/') %}
{% set ext = att.filename.rsplit('.', 1)[-1].lower() if '.' in att.filename else '' %}
{% set is_img = (att.mime_type and att.mime_type.startswith('image/')) or ext in ('png','jpg','jpeg','gif','webp','svg') %}
{% if is_img %}
<a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
target="_blank" class="comment-img-link">
<img src="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
@@ -187,9 +191,12 @@
{% endif %}
<script>
const TICKET_ID = {{ ticket.id }};
const CURRENT_UID = {{ current_user.id }};
const IS_IT_STAFF = {{ 'true' if current_user.is_it_staff else 'false' }};
const TICKET_ID = {{ ticket.id }};
const CURRENT_UID = {{ current_user.id }};
const IS_IT_STAFF = {{ 'true' if current_user.is_it_staff else 'false' }};
const ATTACHMENT_URL = '{{ url_for("tickets.download_attachment", att_id=0) }}'.replace('/0', '/');
const DELETE_COMMENT_URL = '{{ url_for("tickets.delete_comment", comment_id=0) }}'.replace('/0/', '/{id}/');
const AVATAR_URL = '{{ url_for("auth.serve_avatar", filename="__name__") }}'.replace('__name__', '');
const DELETE_URLS = {}; // populated dynamically for new comments
let seenCommentIds = new Set([{% for c in comments %}{{ c.id }},{% endfor %}]);
@@ -449,26 +456,30 @@ function buildCommentEl(c) {
? '<span style="font-size:10px;background:rgba(251,191,36,.15);color:var(--warning);padding:1px 7px;border-radius:4px;font-weight:600;">INTERNAL NOTE</span>'
: '';
const deleteBtn = c.can_delete
? `<form method="POST" action="/comments/${c.id}/delete" onsubmit="return confirm('Delete this comment?');" style="margin:0;">
? `<form method="POST" action="${DELETE_COMMENT_URL.replace('{id}', c.id)}" onsubmit="return confirm('Delete this comment?');" style="margin:0;">
<input type="hidden" name="csrf_token" value="${document.querySelector('meta[name=csrf-token]').content}"/>
<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>
</button>
</form>`
: '';
const IMG_EXTS = new Set(['png','jpg','jpeg','gif','webp','svg']);
const atts = (c.attachments || []).map(a => {
if (a.is_image) {
return `<a href="/attachments/${a.id}" target="_blank" class="comment-img-link">
<img src="/attachments/${a.id}" alt="${a.filename}" class="comment-img-thumb"/>
const url = ATTACHMENT_URL + a.id;
const ext = a.filename.includes('.') ? a.filename.split('.').pop().toLowerCase() : '';
const isImg = a.is_image || IMG_EXTS.has(ext);
if (isImg) {
return `<a href="${url}" target="_blank" class="comment-img-link">
<img src="${url}" alt="${a.filename}" class="comment-img-thumb"/>
</a>`;
}
return `<a href="/attachments/${a.id}" class="btn btn-secondary btn-sm me-1 mb-1">
return `<a href="${url}" class="btn btn-secondary btn-sm me-1 mb-1">
<i class="bi bi-download me-1"></i>${a.filename}
</a>`;
}).join('');
const avatarInner = c.author_avatar
? `<img src="/auth/avatar/${c.author_avatar}" alt="${c.author_init}"
? `<img src="${AVATAR_URL}${c.author_avatar}" alt="${c.author_init}"
style="width:28px;height:28px;object-fit:cover;display:block;border-radius:50%;"/>`
: c.author_init;