discourse/lib/upload_markdown.rb
Régis HANOL 13280c1023
FIX: Escape markdown characters in upload filenames (#39133)
Filenames containing markdown formatting characters (`_`, `*`, `~`, `` `
``, `[`, `]`, `|`) would break upload markup when cooked. For example,
uploading `_test_file_.txt` generated:

    [_test_file_.txt|attachment](upload://...)

The underscores triggered emphasis parsing inside the link text, which
both rendered the filename incorrectly (with italics) and prevented the
`|attachment` marker from being recognized — losing the
`class="attachment"` on the resulting `<a>` tag.

**Markdown generation (defense in depth)**

Add `escapeMarkdownCharacters` (JS) and `UploadMarkdown.escape_markdown`
(Ruby) to backslash-escape all inline formatting characters in filenames
before embedding them in markdown link text. Applied in:

- `UploadMarkdown` — image, attachment, and playable media methods
- `uploads.js` — `attachmentMarkdown` and `markdownNameFromFileName`
- `inline_uploads.rb` — HTML anchor conversion and hotlinked image URLs
- `to-markdown.js` — HTML-to-markdown attachment link reconstruction
- `sanitizeAlt` in `markdown-image-builder.js` — image alt text

**Parser resilience (belt and suspenders)**

The markdown-it `renderAttachment` renderer and ProseMirror's link
parser both assumed `tokens[idx+1]` was a single text token containing
the full link text. When emphasis/bold/strikethrough/code was parsed
inside the link text, the token sequence included formatting tokens and
the `|attachment` marker was lost. Both now scan forward through all
tokens between `link_open` and `link_close` to find the marker.

The image renderer (`renderImageOrPlayableMedia`) split alt text on `|`
assuming the first segment was always the alt and everything after was
structured suffixes (dimensions, video/audio, data attributes). A pipe
in the filename would produce extra segments that confused the dimension
parser. It now scans from the right, consuming known suffixes, and
treats everything remaining as alt text.

https://meta.discourse.org/t/400079
2026-04-14 10:37:41 +02:00

45 lines
1.4 KiB
Ruby

# frozen_string_literal: true
class UploadMarkdown
def initialize(upload)
@upload = upload
end
def to_markdown(display_name: nil)
if FileHelper.is_supported_image?(@upload.original_filename)
image_markdown(display_name: display_name)
elsif FileHelper.is_supported_playable_media?(@upload.original_filename)
playable_media_markdown(display_name: display_name)
else
attachment_markdown(display_name: display_name)
end
end
def image_markdown(display_name: nil)
display_name ||= @upload.original_filename
"![#{self.class.escape_markdown(display_name)}|#{@upload.width}x#{@upload.height}](#{@upload.short_url})"
end
def attachment_markdown(display_name: nil, with_filesize: true)
human_filesize = with_filesize ? " (#{@upload.human_filesize})" : ""
display_name ||= @upload.original_filename
"[#{self.class.escape_markdown(display_name)}|attachment](#{@upload.short_url})#{human_filesize}"
end
def playable_media_markdown(display_name: nil)
type =
if FileHelper.is_supported_audio?(@upload.original_filename)
"audio"
elsif FileHelper.is_supported_video?(@upload.original_filename)
"video"
end
return attachment_markdown if !type
display_name ||= @upload.original_filename
"![#{self.class.escape_markdown(display_name)}|#{type}](#{@upload.short_url})"
end
def self.escape_markdown(text)
text.gsub(/[\\*_~`\[\]|]/) { |c| "\\#{c}" }
end
end