Update
This commit is contained in:
+43
-3
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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.')
|
||||||
Reference in New Issue
Block a user