I’ve debugged the problem and have a potential fix (with the help of Gemini).
I created this message (the content of the dragged file is not important) on version 3.4.5 (which I knew worked) and the latest version:
I then used 2 others accounts to download both files and refreshed the page.
I didn’t see a download counter for the “without-attachement” (even though it worked when I used source v3.4.5). “with-attachment” never showed download counter, I only added it for “completeness”.
When execution this in the debugger:
post = Post.last
TopicLink.counts_for(Guardian.new(post.user), post.topic, [post])
I got:
=> {8 =>
[{url: "/uploads/short-url/iMUBjeLNs0eRWZhdYnh24XGXY0f.funscript", clicks: 2, title: nil, internal: true, reflection: false},
{url: "/uploads/short-url/rgnPszmhmEizcRVBobULTkMP1UI.funscript", clicks: 0, title: nil, internal: true, reflection: false}]}
So the clicks were there, but the UI didn’t consider them because there were filtered by the rule to prevent users from seeing click counts for links pointing to private/hidden topics. The problem is that the new rule doesn’t take into account that an internal link can be a topic OR an internal file. Anything internal and not a topic is currently discarded.
I was able to resolve the problem by changing (in topic_link.rb):
def self.apply_link_visibility_filters(builder, link:, target_topic:, target_posts:)
builder.where(<<\~SQL)
#{target_topic}.deleted_at IS NULL
AND (#{target_topic}.id IS NULL OR #{target_topic}.visible = true)
AND (#{link}.internal = false OR #{target_topic}.id IS NOT NULL)
AND (#{link}.link_post_id IS NULL OR (#{target_posts}.id IS NOT NULL AND #{target_posts}.deleted_at IS NULL))
SQL
end
to this:
def self.apply_link_visibility_filters(builder, link:, target_topic:, target_posts:)
builder.where(<<\~SQL)
#{target_topic}.deleted_at IS NULL
AND (#{target_topic}.id IS NULL OR #{target_topic}.visible = true)
AND (#{link}.internal = false OR #{link}.link_topic_id IS NULL OR #{target_topic}.id IS NOT NULL)
AND (#{link}.link_post_id IS NULL OR (#{target_posts}.id IS NOT NULL AND #{target_posts}.deleted_at IS NULL))
SQL
end
Gemini full explanation
You found the exact smoking gun! By providing the two versions of topic_link.rb, it is crystal clear exactly what broke this feature in the latest version of Discourse.
The issue is entirely caused by a new security filter added to the database query in counts_for.
The Culprit: The new apply_link_visibility_filters method
If you look near the bottom of the new topic_link.rb file, you will find this new method:
codeRuby
def self.apply_link_visibility_filters(builder, link:, target_topic:, target_posts:)
builder.where(<<~SQL)
#{target_topic}.deleted_at IS NULL
AND (#{target_topic}.id IS NULL OR #{target_topic}.visible = true)
AND (#{link}.internal = false OR #{target_topic}.id IS NOT NULL) <--- THIS IS THE BUG
AND (#{link}.link_post_id IS NULL OR (#{target_posts}.id IS NOT NULL AND #{target_posts}.deleted_at IS NULL))
SQL
end
Why this breaks standard file uploads
Discourse developers added this SQL rule to prevent users from seeing click counts for links pointing to private/hidden topics they don’t have permission to view. However, they accidentally caught file uploads in the crossfire.
Here is exactly how the SQL engine evaluates your without-attachment.funscript upload link:
- #{link}.internal = false: When an upload is saved, Discourse marks it as internal = true (because the file is hosted on your forum). So this evaluates to FALSE.
- #{target_topic}.id IS NOT NULL: Because this is a file upload and not a link to another forum post, it doesn’t have a target topic. The topic ID is NULL. So this also evaluates to FALSE.
- FALSE OR FALSE = FALSE.
Because both conditions fail, the SQL query completely filters out your file upload, and the counts_for method returns an empty array to the serializer.
(Note: The reason |attachment still works is because the frontend handles .attachment classes via a completely separate API endpoint/mechanism for download tracking, bypassing TopicLink entirely!)
How to fix the code
To fix this bug in your local environment, you need to tell the SQL query: “It’s okay if an internal link doesn’t have a target topic, as long as it was never supposed to have one to begin with (like a file upload).”
Open app/models/topic_link.rb in your Dev Container, find the apply_link_visibility_filters method (around line 185), and change the broken line to this:
Change this:
AND (#{link}.internal = false OR #{target_topic}.id IS NOT NULL)
To this:
AND (#{link}.internal = false OR #{link}.link_topic_id IS NULL OR #{target_topic}.id IS NOT NULL)
Why this fixes it:
By adding #{link}.link_topic_id IS NULL, you are telling the database to allow internal links that are purely files/uploads. It will still successfully block links that do have a link_topic_id but the target_topic is missing/hidden (which satisfies the security requirement the Discourse devs were trying to achieve).