diff --git a/app/routes/tickets.py b/app/routes/tickets.py index 4a0bb25..b570d6b 100644 --- a/app/routes/tickets.py +++ b/app/routes/tickets.py @@ -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, ) diff --git a/app/templates/tickets/detail.html b/app/templates/tickets/detail.html index b50ff15..74630ac 100644 --- a/app/templates/tickets/detail.html +++ b/app/templates/tickets/detail.html @@ -41,7 +41,9 @@
{% 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 %} {% 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 %} -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) { ? 'INTERNAL NOTE' : ''; const deleteBtn = c.can_delete - ? `
+ ? `
` : ''; + const IMG_EXTS = new Set(['png','jpg','jpeg','gif','webp','svg']); const atts = (c.attachments || []).map(a => { - if (a.is_image) { - return `
- ${a.filename} + 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.filename} `; } - return ` + return ` ${a.filename} `; }).join(''); const avatarInner = c.author_avatar - ? `${c.author_init}` : c.author_init; diff --git a/migrations/versions/002_add_system_settings.py b/migrations/versions/002_add_system_settings.py index e6ba160..d476872 100644 --- a/migrations/versions/002_add_system_settings.py +++ b/migrations/versions/002_add_system_settings.py @@ -29,23 +29,32 @@ depends_on = None def upgrade(): - op.create_table( - 'system_settings', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('key', sa.String(100), nullable=False), - sa.Column('value', sa.String(500), nullable=False), - sa.Column('description', sa.String(256), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id'), - ) - op.create_index('ix_system_settings_key', 'system_settings', ['key'], unique=True) - - # Seed the default: registration is open on fresh installs. - op.execute( - "INSERT INTO system_settings (key, value, description, updated_at) " - "VALUES ('registration_enabled', 'true', " - "'Allow new users to self-register via /auth/register', NOW())" - ) + # 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( + 'system_settings', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('key', sa.String(100), nullable=False), + sa.Column('value', sa.String(500), nullable=False), + sa.Column('description', sa.String(256), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('ix_system_settings_key', 'system_settings', ['key'], unique=True) + op.execute( + "INSERT INTO system_settings (key, value, description, updated_at) " + "VALUES ('registration_enabled', 'true', " + "'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(): diff --git a/migrations/versions/003_render_existing_comment_bodies.py b/migrations/versions/003_render_existing_comment_bodies.py new file mode 100644 index 0000000..1b7b647 --- /dev/null +++ b/migrations/versions/003_render_existing_comment_bodies.py @@ -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.')