Related:
https://meta.discourse.org/t/yet-another-title-localization-issue/395469
### bug context
tldr; `Topic#fancy_title` saves a fancy_title to db when the fancy_title
is null.
This bug requires a certain incantation to trigger.
- Topic 395465 exists with `title = "Notification level button always
says \"tracking\""` and
`fancy_title = NULL` in the DB
- A `TopicLocalization` exists for this topic in `zh_CN`
- `content_localization_enabled` is on
When a crawler hits `GET /t/.../395465?tl=zh_CN`, localization
replacement happens on the topic, which writes the title attribute, so
the state is now
- `title` = Chinese (modified)
- `fancy_title` = NULL (untouched, still what was loaded from DB)
When serializing via `TopicViewSerializer` which uses
`LocalizedFancyTopicTitleMixin`, we call `topic.fancy_title`. The
`topic#fancy_title` generates the fancy_title from the title value, then
writes the chinese fancy title to db 😢https://github.com/discourse/discourse/blob/e935ed63b28a30ee7ae6a7783ae05fe33edf3367/app/models/topic.rb#L532-L545
### fix
This commit fixes the issue by ensuring the fancy_title is always written
along with the title, preventing the need for invoking
`topic#fancy_title`.
## Summary
The original commit added a new Discourse ID settings page with the
ability to enable/disable Discourse ID and regenerate credentials.
However, the `update_settings` action used direct assignment to
`SiteSetting.enable_discourse_id` which bypasses the staff action
logging mechanism, meaning changes to this setting were not being
recorded in the admin logs.
Followup 42da6860fd
When the upcoming change for "Impersonate without logout" was added, we
didn't take into account that the "stop impersonation" action would not
work if the Staff enabled option for the upcoming change was used.
This was happening because in the ImpersonateController#destroy action
we werecheckingif the current user had `impersonate_without_logout`
enabled, but we should be checking if the acting user had that
permission instead (i.e. the original admin not the user they are
impersonating)
c.f.
https://meta.discourse.org/t/new-bug-with-experimental-impersonation-interface/395621
We can use the status of upcoming changes to indicate
whether they are experimental or not, having experimental
in the setting name is redundant.
Migrates the settings and the upcoming change events,
updates code, and updates yaml translation keys.
The UserSerializer exposes post_count as a staff attribute but does not
expose topic_count, even though the underlying data is available on the
UserStat model. This means consumers like the Discourse AI tool API's
discourse.getUser() function can retrieve how many replies a user has
made but not how many topics they have created, giving an incomplete
picture of user activity.
Add topic_count as a staff attribute in UserSerializer, mirroring the
existing post_count implementation. Also add spec coverage for all four
staff attributes (post_count, topic_count, can_be_deleted,
can_delete_all_posts) to verify they are included for staff scopes and
excluded for non-staff scopes.
Ref: https://meta.discourse.org/t/394878
In https://github.com/discourse/discourse/pull/36678 we introduced the
`tag_ids` param for topic creation and updating to add tags to a topic,
however, this does not cater for new tags which are allowed based on the
UI and previous API.
Furthermore, the frontend wasn't even using the new param, making the
new `tag_ids` implementation on the controller moot.
This commit
- removes this `tag_id` param
- updates the deprecation note to the `tags` param to require `[{ id: 1,
name: "old1" }, { name: "new1" }]` rather than the old `["old1",
"new1"]`.
- including IDs here eliminates the case where John renames `"old1"` to
`"old2"`, but Mary sends `["old1", "new1"]`, thus creating the `"old1"`
tag again when it was already renamed.
- keeps us consistent with other endpoints that take in `tags`
Reviewer note: The `tag_topic_by_names` method is [extremely
complicated](https://github.com/discourse/discourse/blob/2b24fc91c1f46e57913d6acb7aee0c05041f64be/lib/discourse_tagging.rb#L19-L248).
Ideally, we would like to have a `tag_topics` method that does not incur
that many `Tag` queries, but to prevent a large refactor here, we are
re-using `tag_topic_by_names`.
What is the problem?
When creating or editing a tag group, users can type new tag names into
the `TagChooser` (which has `allowAny=true`). The select-kit component
assigns a string-based ID to these new tags. `TagGroupsController#tag_groups_params`
blindly extracts `id` from each tag object, producing the string name
instead of a database ID for new tags. `TagGroup#tag_ids=` then silently
ignores these invalid values, so the new tags are dropped without error.
The same issue affects parent tags.
This was introduced in 9e99066b07 which changed tags from string arrays
to object arrays but did not handle the case where new tags have no
numeric ID.
What is the solution?
On the frontend, the `TagGroupsForm#save` action now strips non-numeric
IDs from tags and parent tags before sending to the backend via a private
`#serializeTag` method. Tags with a numeric `id` (existing) keep both
`id` and `name`; tags with a string `id` (new) are sent with only `name`.
On the backend, `TagGroupsController#tag_groups_params` now splits the
tags array into existing (have `id`) and new (no `id`). New tag names are
passed to `DiscourseTagging.find_or_create_tags!` which validates names via
`tags_for_saving` and creates them. The same logic applies to parent
tags.
When a color scheme has a base_scheme, the base scheme's
color_scheme_colors were not being eager loaded, causing strict loading
violations.
This updates the includes statement to properly load the nested
association `base_scheme: :color_scheme_colors` instead of just
`:base_scheme`.
Also adds a test that creates color schemes with base_scheme
relationships to verify the associations are properly loaded without
strict loading errors.
The original commit moved the topic merging operation into a `hijack`
block to avoid Unicorn worker timeouts when merging topics with many
posts. However, it omitted error handling that exists in the similar
`move_posts` method, which could lead to 500 errors with empty bodies in
production when `ActiveRecord::RecordInvalid` or
`ActiveRecord::RecordNotSaved` exceptions occur.
Without this we will get no information about why merging did not work
What is the problem?
The flagged posts count on user profiles only shows
`ReviewableFlaggedPost` items
and excludes other reviewable types like `ReviewableQueuedPost`,
`ReviewableUser`,
and plugin-provided reviewables (chat, AI, etc.).
`User#number_of_flagged_posts` queries only `ReviewableFlaggedPost`
records, and
the review queue link includes `type="ReviewableFlaggedPost"` as a query
parameter
to scope results. This has been confusing for staff who expect the count
to reflect
all pending review items for a user, not just flagged posts.
What is the solution?
Product has decided to change the label from "X flagged posts" to "X
flags" and
stop scoping to just `ReviewableFlaggedPost`, so the counter reflects
all
reviewables for the user.
1. Rename `User#number_of_flagged_posts` to `User#number_of_flags` and
query
all `Reviewable` records instead of just `ReviewableFlaggedPost`
2. Remove `type="ReviewableFlaggedPost"` from the review queue link
query params
so clicking through shows all reviewables matching the username filter
3. Rename the translation key from `flagged_posts` to `flags` and CSS
class from
`.flagged-posts` to `.flags`
## 🔍 Overview
This update ensures that we fix some typos in the codebase, along with a broken CSS selector, accidental debug statements, and invalid references.
Currently in several endpoints, we return an array of strings for tags.
Our goal with this PR is to expand array tag name strings to an array of
tag objects.
#### before: Tags were returned as string arrays
```
{ "tags": ["support", "bug-report"] }
```
#### after: Tags are returned as object arrays
```
{ "tags": [{"id": 12, "name": "support", "slug": "support"}, {"id": 13, "name": "bug-report", "slug": "bug-report"}] }
```
This allows us to start referencing tags by their ids, and return more
information for a tag for future features.
This commit involves updating several areas:
- topic lists (/latest.json, /top.json, /c/:category/:id.json, etc, for
`top_tags`)
- tag chooser components (`MiniTagChooser`, `TagDrop`, etc)
- topic view (/t/:id.json)
- tag groups (/tag_groups.json, tags, parent_tag)
- category settings
- staff action logs
- synonyms
- ...
APIs that reference tags based on their names will still be supported
with a deprecation warning. Moving on, we will reference them using
their tag ids.
Previously, moderators had full access to all staff action logs, which
exposed sensitive information including webhook secrets, API keys, site
settings, private messages, and restricted categories.
This change implements an allowlist approach where moderators can only
see actions relevant to their role (user management, posts, topics,
badges, etc.) while admin-only actions (site settings, webhooks, API
keys, themes, etc.) are hidden.
Additionally, content-level redaction ensures moderators cannot see
details of logs referencing private topics, restricted categories, or
deleted content they don't have access to.
Site setting gates control visibility of category, trust level, and
email actions based on existing moderator permission settings.
Ref - t/171137
Introduces a `Report.hidden?` class method that consolidates all report
visibility checks into a single location. This replaces duplicated
conditional logic that was scattered across the controller and query
classes.
The new method handles:
- Admin-only reports (e.g., `top_uploads`) that moderators cannot access
- Legacy pageview report visibility based on `use_legacy_pageviews` setting
Previously, the controller's `#bulk` and `#show` actions each had their
own inline checks for hidden reports, and `Reports::ListQuery` duplicated
this logic again. Now all three locations delegate to `Report.hidden?`,
making the visibility rules easier to maintain and extend.
To prevent accidental privilege escalation, the `admin:` keyword argument
is required with no default value. A forgotten parameter now raises an
`ArgumentError` rather than silently granting admin access. This parameter
flows from `current_user.admin?` in both the reports controller and the
admin search controller through to the query and model, ensuring
consistent access control.
Ref - t/171141
Permalinks pointing to access-restricted resources (private topics,
categories, posts, or hidden tags) were redirecting users to URLs
containing the resource slug, even when the user didn't have access.
This leaked potentially sensitive information (e.g., private topic
titles) via the redirect Location header and the 404 page's search box.
This fix adds access checks via a new `PermalinkGuardian` module before
redirecting or returning target URLs. If the current user cannot see
the target resource, a 404 is returned instead.
Also fixes `Guardian#can_see_tag?` to properly check hidden tag
visibility instead of always returning true.
Ref - t/172554
Use `description_text` instead of `description` for the category
meta description to ensure HTML tags are stripped from the
og:description and twitter:description meta tags.
**What is the problem?**
`CategoriesController.topics_per_page` counted all top-level categories
regardless of user permissions. On sites with many restricted
categories, this caused excessive topic fetching for users who could
only see a small number of categories.
**What is the solution?**
Use `Category.secured(guardian)` to only count categories visible to the
current user and add a maximum cap of 100 topics as a safety net.
Add test coverage to ensure crawler-rendered HTML includes post cooked
content. This helps catch regressions where crawler views might show
topic titles but miss post bodies, which can negatively impact SEO and
search engine indexing.
This commit adds several pieces of functionality to help keep admins
in the loop about upcoming changes.
First of all, there is a new initializer on boot that will notify admins
about
newly available upcoming changes, as well as log removed changes and
status movement of existing changes.
* When there is a new upcoming change, we only notify admins about
it when the status is the `promote_upcoming_changes_on_status` - 1,
e.g. if `promote_upcoming_changes_on_status` is `beta` then we only
tell admin about the change once it has reached `alpha`. This means
we may log the `added` event in one deploy, but only actually notify
admins in a subsequent deploy.
* We log removed upcoming changes so we can automatically delete old
site setting data in a future job as needed.
We also now notify admins when upcoming changes are automatically
promoted to enabled based on the site's
`promote_upcoming_changes_on_status`:
<img width="378" height="600" alt="image"
src="https://github.com/user-attachments/assets/4200fbee-9990-4bbc-a378-85946e631e77"
/>
In addition, we now show an indicator in the admin sidebar
if there are new upcoming changes that have been added since
they last visited the upcoming change config page. This data
is stored in a user custom field, because Redis is ephemeral,
and storing in the User table is overkill because 99% of users
are not staff:
<img width="248" height="112" alt="image"
src="https://github.com/user-attachments/assets/4c3d3cf7-ac39-45f8-a2c8-a049cb85b8e9"
/>
Finally, this commit moves both the Track and Promote initializer
logic behind a `DistributedMutex`, we don't want multiple processes
running the same logic here, it needs to be only once.
---------
Co-authored-by: Loïc Guitaut <loic@discourse.org>
Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
This is default enabled, but some admins can opt to disable email invites.
When disabled, users can still create invites, but only copy/paste links.
Co-authored-by: Keegan George <kgeorge13@gmail.com>
Follow-up to #37005.
Non-image file uploads for site settings (like llms_txt) were failing
with "Sorry, you can only upload images for this setting" when using S3
storage. This happened because the external upload completion endpoints
weren't passing site_setting_name to the upload validator.
Without site_setting_name, the validator can't look up the setting's
authorized_extensions and falls back to allowing only images.
The direct upload path (UploadsController#create) already handled this
correctly - this ensures the S3 paths match that behavior:
- complete-external-upload: now extracts and passes site_setting_name
- complete-multipart: now sends for_site_setting and site_setting_name
from the frontend (for consistency, though site settings don't currently
use multipart uploads)
Adds the ability to serve a custom llms.txt file at /llms.txt, allowing
site admins to provide information for LLM crawlers about the site's
structure and important content.
The implementation uses a new `llms_txt` upload site setting where
admins can upload a .txt or .md file (max 512KB). The file is served via the
StaticController, following the same pattern as favicon.
For external storage (S3), the content is cached in Redis using the
upload's SHA1 as the cache key - this means the cache automatically
invalidates when a new file is uploaded. For local storage, the file
is served directly via send_file.
Blocked crawlers are allowed to access /llms.txt, similar to robots.txt.
Ref - t/162690
Requires - https://github.com/discourse/discourse/pull/36939
When selecting various posts from the search UI to be deleted using the
bulk actions, an error can be encountered saying, "You are not permitted
to view the requested resource". That’s because it doesn’t work when the
post is the first post of a topic.
This patch addresses the issue by checking if the user can either
destroy the post or the related topic. The logic for destroying a topic
is the same as the one for destroying a post since in both cases we’re
calling `PostDestroyer#destroy`.
When updating a group's notification defaults (like
watching_category_ids) via API, if existing members would be affected,
the server returns a 422 asking the caller to specify whether to apply
changes to existing users.
The previous error message was confusing: "You supplied invalid
parameters to the request: update_existing_users" - even though the
caller hadn't supplied that parameter at all. The new message clearly
explains what's happening and what to do:
"This change affects X existing group members. You must specify the
'update_existing_users' parameter (true or false) to indicate whether to
apply the new notification defaults to existing members."
Also fixed a bug where the confirmation modal state wasn't being reset
when navigating between groups, causing the modal to not appear on
subsequent group edits. The modal now returns its result directly
instead of storing it in component state.
Ref - https://meta.discourse.org/t/-/393572
Previously, locale bundles were using `require()` directly, and were
very sensitive to load order. This made it very hard to refactor things,
especially for our upcoming move to Vite.
This commit updates the four types of locale bundle to contain only very
simple JS code, with no dependencies at the top level. They simply add
POJO and functions to the `window._discourse_locale_data` global. When
`discourse-i18n` is loaded, it checks that global, loads up the data,
and executes the functions (passing in any dependencies like
messageformat/runtime where required).
Changes in the HTML files are to bring the locale bundles up to the
first position, since they no longer have any dependencies, and we need
to be 100% sure they're loaded before discourse-i18n.
Tests are updated for the new bundle format, and the most complex ones
have been converted to system specs, so that we no longer need to create
a mock environment for executing messageformat/discourse-i18n/etc.
`lib/deprecated` changes (and associated pretty-text changes) are to
make the dependencies more formalized, because `require()` is subject to
race conditions, which started being hit following the locale refactor.
---------
Co-authored-by: Jarek Radosz <jradosz@gmail.com>
What is the problem?
The `ReviewablePerformResultSerializer` includes `transition_to` and
`transition_to_id` attributes in API responses, but these values are
not consumed by any frontend code. The frontend only uses
`remove_reviewable_ids`, `reviewable_count`, `unseen_reviewable_count`,
and `completed_message` from the perform result.
What is the solution?
Remove the unused `transition_to` and `transition_to_id` attributes
from `ReviewablePerformResultSerializer`, along with the
`transition_to_id` method. Update corresponding test expectations.
Adds a new `bypass_bump` parameter to the post update endpoint that
allows privileged users (staff and TL4) to update a post without bumping
the topic.
The parameter can be passed as either a query param
(`?bypass_bump=true`) or in the request body (`post[bypass_bump]=true`).
When omitted, behavior is unchanged - edits to the last post in a topic
will bump it as usual.
Closes#34712
Co-authored-by: Ethan M <176145523+Ethsim12@users.noreply.github.com>
Silenced users can now no longer like posts or use reactions, which
closes a potential griefing vector that was difficult for moderators
to monitor.
The implementation adds a silenced check to the guardian's post_can_act?
method for likes, and introduces a new can_use_reactions? guardian method
in the discourse-reactions plugin that delegates to the same logic. This
ensures both features share the same authorization path.
Additionally, silenced users' custom status is now shadow-banned: visible
to themselves and staff, but hidden from other users.
A new `can_see_user_status?` guardian method centralizes the visibility
logic, used by serializers and MessageBus publishing. Status updates
from silenced users are now only broadcast to themselves and staff.
Also includes minor CSS fixes for user status spacing and alignment.
Chat reactions already had proper silenced user checks in place via the
can_react? guardian method, so no changes were needed there.
Ref - t/140084
What is the problem?
The `parent_tag_name` parameter is accepted by both `#create` and
`#update` actions in `TagGroupsController`, but there are no tests
verifying this functionality works correctly.
What is the solution?
Add two test cases to verify that creating and updating tag groups
with a `parent_tag_name` parameter correctly sets the parent tag
association and returns it in the response.
What is the problem?
The spec for `TagGroupsController#update` had several issues:
1. Request params were incorrectly nested (`tag_group: { tag_group:
{...} }`)
2. No assertion verified that the tags were actually updated
3. Expected subject in `UserHistory` was the old name instead of the new
name
What is the solution?
Fix the params structure, add an assertion to verify the tags are
updated
correctly, and update the expected subject to match the new tag group
name.