The original commit
https://github.com/discourse/discourse/commit/ce2ca19d added a
frontend-only restriction to hide the "delete all posts" option when penalizing users at trust level 2 or above. However, this restriction was not enforced on the server side.
This is not security as it's mostly a safeguard and not a security feature. The same user could delete posts one by one if they wanted to.
There's no significant security issue as this will get caught by a
guardian later and the rate limiting is scoped to the remote ip, so an
attacker can't rate limit a victim and prevent them to use this
endpoint.
With `content_localization_enabled`, searching for tags via any tag
dropdowns (or hashtag autocompletes) triggers a 500 error. The crash
occurs because a preloader is called on a non-AR result returned by
`DiscourseTagging.filter_allowed_tags` :sadpikachu:, which are plain
structs, not AR Tags instances. Additionally, `tag_counts_json` calls AR
methods (which access `.locale` that don't exist on the minisql objects.
This PR fixes the issue by converting mini results to Tag ARs with
`.includes(:localizations)` before passing them to tag_counts_json.
- TagsController#search
- TagHashtagDataSource#search
- TagHashtagDataSource#search_without_term
Categories with accented names (e.g. "Éditions") were not appearing in
search results, category chooser dropdowns, or hashtag autocomplete when
searching with unaccented terms (e.g. "editions").
PostgreSQL's `ILIKE` and `LOWER()` are case-insensitive but not
accent-insensitive, so queries like `name ILIKE '%editions%'` would not
match "Éditions".
This wraps all category name/slug comparisons with PostgreSQL's
`unaccent()` function across:
- `/categories/search` endpoint
- `/categories/hierarchical_search` endpoint
- Hashtag (#) autocomplete
- `category:` and `#slug` search filters
Introduces `Category.normalize_sql(expr)` helper that wraps SQL
expressions with `lower(unaccent(...))` to centralize the normalization
logic and make it easier to extend in the future.
On the frontend, adds a shared `removeAccents()` utility using NFD
normalization for client-side `Category.search()` and search filter
suggestions.
Ref - https://meta.discourse.org/t/395355
What is the problem?
The tag groups search endpoint (`TagGroupsController#search`) returns
tag data with only `id` and `name` fields. The `slug` field is missing
from the response, which can cause issues on the client side when it
needs the slug to build URLs or match tags correctly — especially for
tags whose slug differs from their name.
What is the solution?
Add `slug` to the `pluck` and `map` calls in
`TagGroupsController#search` so the response includes the tag slug
alongside `id` and `name`.
This is an admin only feature, we had not validation that the webhook
event we were trying to redeliver was part of the webhook
This cleans this up and makes code more consistent
We were using `Discourse.InvalidParameters(...)` instead of
`Discourse::InvalidParameters.new(...)` which would raise a
`NoMethodError` (500) instead of the correct invalid parameters error.
This codepath was not tested and this commit adds a test for it.
Switches tag URLs from name-based (`/tag/my-tag`) to slug+id-based
(`/tag/my-tag/123`), making tag references stable across renames and
enabling translated tag names.
| Type | Before | After |
|------|--------|-------|
| Browser | `/tag/my-tag` | `/tag/my-tag/123` |
| Browser | `/tag/my-tag/l/latest` | `/tag/my-tag/123/l/latest` |
| API | `/tag/my-tag.json` | `/tag/123.json` |
- Old `/tag/:name` URLs still work
- Browser requests get 301 redirected to canonical URLs
- API (JSON) requests return data without redirect
- Untagged (`/tag/none`) and intersection routes remain unchanged.
Depends on https://github.com/discourse/discourse/pull/36678
---------
Co-authored-by: Krzysztof Kotlarek <kotlarek.krzysztof@gmail.com>
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.