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'} 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): def save_attachment(file, ticket_id=None, comment_id=None, uploader_id=None):
filename = secure_filename(file.filename) filename = secure_filename(file.filename)
ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else '' 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) db.session.add(att)
return 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 ──────────────────────────────────────────────────────────────── # ─── Dashboard ────────────────────────────────────────────────────────────────
@@ -382,13 +421,14 @@ def download_attachment(att_id):
) )
abort(403) abort(403)
upload_dir = current_app.config['UPLOAD_FOLDER'] 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( return send_from_directory(
upload_dir, upload_dir,
att.stored_name, att.stored_name,
as_attachment = not is_image, # images render inline; other files force-download as_attachment = not is_image,
download_name = att.filename, download_name = att.filename,
mimetype = att.mime_type or None, mimetype = mime,
) )
+19 -8
View File
@@ -41,7 +41,9 @@
<div class="card-body"> <div class="card-body">
<div class="d-flex flex-wrap gap-2 align-items-start"> <div class="d-flex flex-wrap gap-2 align-items-start">
{% for att in ticket_atts %} {% 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) }}" <a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
target="_blank" class="comment-img-link" title="{{ att.filename }}"> target="_blank" class="comment-img-link" title="{{ att.filename }}">
<img src="{{ url_for('tickets.download_attachment', att_id=att.id) }}" <img src="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
@@ -124,7 +126,9 @@
{% if c_atts %} {% if c_atts %}
<div class="mt-2"> <div class="mt-2">
{% for att in c_atts %} {% 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) }}" <a href="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
target="_blank" class="comment-img-link"> target="_blank" class="comment-img-link">
<img src="{{ url_for('tickets.download_attachment', att_id=att.id) }}" <img src="{{ url_for('tickets.download_attachment', att_id=att.id) }}"
@@ -190,6 +194,9 @@
const TICKET_ID = {{ ticket.id }}; const TICKET_ID = {{ ticket.id }};
const CURRENT_UID = {{ current_user.id }}; const CURRENT_UID = {{ current_user.id }};
const IS_IT_STAFF = {{ 'true' if current_user.is_it_staff else 'false' }}; 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 const DELETE_URLS = {}; // populated dynamically for new comments
let seenCommentIds = new Set([{% for c in comments %}{{ c.id }},{% endfor %}]); 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>' ? '<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 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}"/> <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"> <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> <i class="bi bi-trash"></i>
</button> </button>
</form>` </form>`
: ''; : '';
const IMG_EXTS = new Set(['png','jpg','jpeg','gif','webp','svg']);
const atts = (c.attachments || []).map(a => { const atts = (c.attachments || []).map(a => {
if (a.is_image) { const url = ATTACHMENT_URL + a.id;
return `<a href="/attachments/${a.id}" target="_blank" class="comment-img-link"> const ext = a.filename.includes('.') ? a.filename.split('.').pop().toLowerCase() : '';
<img src="/attachments/${a.id}" alt="${a.filename}" class="comment-img-thumb"/> 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>`; </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} <i class="bi bi-download me-1"></i>${a.filename}
</a>`; </a>`;
}).join(''); }).join('');
const avatarInner = c.author_avatar 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%;"/>` style="width:28px;height:28px;object-fit:cover;display:block;border-radius:50%;"/>`
: c.author_init; : c.author_init;
+11 -2
View File
@@ -29,6 +29,11 @@ depends_on = None
def upgrade(): def upgrade():
# Guard: db.create_all() on first startup already creates this table.
# Only create it if it doesn't exist so the migration is idempotent.
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'system_settings' not in inspector.get_table_names():
op.create_table( op.create_table(
'system_settings', 'system_settings',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
@@ -39,13 +44,17 @@ def upgrade():
sa.PrimaryKeyConstraint('id'), sa.PrimaryKeyConstraint('id'),
) )
op.create_index('ix_system_settings_key', 'system_settings', ['key'], unique=True) op.create_index('ix_system_settings_key', 'system_settings', ['key'], unique=True)
# Seed the default: registration is open on fresh installs.
op.execute( op.execute(
"INSERT INTO system_settings (key, value, description, updated_at) " "INSERT INTO system_settings (key, value, description, updated_at) "
"VALUES ('registration_enabled', 'true', " "VALUES ('registration_enabled', 'true', "
"'Allow new users to self-register via /auth/register', NOW())" "'Allow new users to self-register via /auth/register', NOW())"
) )
# If the table already exists (created by db.create_all), ensure the
# index exists — create_all does not create named Alembic indexes.
else:
existing_indexes = [idx['name'] for idx in inspector.get_indexes('system_settings')]
if 'ix_system_settings_key' not in existing_indexes:
op.create_index('ix_system_settings_key', 'system_settings', ['key'], unique=True)
def downgrade(): def downgrade():
@@ -0,0 +1,98 @@
"""Render existing plain-text comment bodies to sanitized HTML.
Revision ID: 003_render_comments
Revises: 002_add_system_settings
Create Date: 2026-03-31
Rationale
---------
Feature #8 (Markdown rendering) stores rendered HTML in Comment.body at
write time. Comments created before this feature are stored as plain text
and must be migrated so all bodies are consistently sanitized HTML.
Also widens alembic_version.version_num from VARCHAR(32) to VARCHAR(64)
to accommodate longer revision ID strings.
Apply
-----
flask db upgrade
Rollback
--------
flask db downgrade
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.orm import Session
revision = '003_render_comments'
down_revision = '002_add_system_settings'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
inspector = sa.inspect(bind)
# ── 1. Widen alembic_version.version_num to VARCHAR(64) ──────────────────
# Flask-Migrate creates this column as VARCHAR(32). Long revision IDs
# (>32 chars) cause DataError when Alembic tries to record the new head.
# This widen is idempotent — safe to run even if already widened.
op.execute(
"ALTER TABLE alembic_version "
"MODIFY COLUMN version_num VARCHAR(64) NOT NULL"
)
# ── 2. Add body_plain_backup column (idempotent) ──────────────────────────
existing_cols = [c['name'] for c in inspector.get_columns('comments')]
if 'body_plain_backup' not in existing_cols:
op.add_column(
'comments',
sa.Column('body_plain_backup', sa.Text(), nullable=True),
)
# ── 3. Render plain-text comment bodies to sanitized HTML ─────────────────
session = Session(bind=bind)
from app.services.validation_service import render_comment_body
rows = session.execute(sa.text('SELECT id, body FROM comments')).fetchall()
updated = 0
for row in rows:
comment_id, body = row[0], row[1]
if not body:
continue
# Skip bodies already rendered as HTML (start with an opening tag).
if body.lstrip().startswith('<'):
continue
rendered = render_comment_body(body)
session.execute(
sa.text(
'UPDATE comments '
'SET body_plain_backup = :plain, body = :rendered '
'WHERE id = :id'
),
{'plain': body, 'rendered': rendered, 'id': comment_id},
)
updated += 1
session.commit()
print(f'[MIGRATION 003] Rendered {updated} plain-text comment bodies to HTML.')
def downgrade():
bind = op.get_bind()
session = Session(bind=bind)
session.execute(sa.text(
'UPDATE comments '
'SET body = body_plain_backup '
'WHERE body_plain_backup IS NOT NULL'
))
session.commit()
op.drop_column('comments', 'body_plain_backup')
print('[MIGRATION 003] Downgrade complete — plain-text bodies restored.')