Commit Graph
14125 Commits
Author SHA1 Message Date
Loïc Guitaut a633007bae DEV: Replace Ruby numbered parameters by it where applicable (#37810)
Now that we moved to Ruby 3.4, we can use `it` instead of `_1`.
2026-02-13 13:59:07 +01:00
Régis Hanol 01c17ee5e7 UX: Show bulk action errors in modal instead of generic toast (#37807)
When bulk operations partially fail (e.g. moving topics to a category
that doesn't allow their tags), the modal closes and a vague toast says
"could not be completed for X topics" with no explanation. Admins have
to check server logs to understand what went wrong.

Keep the modal open and display a detailed summary:
- how many topics were updated
- how many were already up to date (skipped)
- how many could not be updated, with the specific error messages

Backend: expose `TopicsBulkAction#errors` via `attr_reader` and include
the errors hash in the bulk endpoint JSON response when present.

Frontend: accumulate errors across chunked requests in `_processChunks`,
then display the results in the modal body instead of closing it. The
topic list is refreshed when the user dismisses the modal via "Close".

**BEFORE (toast)**

<img width="484" height="72" alt="2026-02-13 @ 09 37 54"
src="https://github.com/user-attachments/assets/a6faaf18-e135-4dda-a4c6-5f1467acf7a1"
/>

**AFTER (details in modal)**

<img width="805" height="328" alt="2026-02-13 @ 09 19 36"
src="https://github.com/user-attachments/assets/cafbe851-9016-4f46-9a8a-8d8f3ceb70cc"
/>
2026-02-13 13:52:01 +01:00
Joffrey JAFFEUX df0cfc4dbe DEV: code cleanup (#37809)
- Removes double private keyword
- Fixes test mutating json as string, instead of json as hash. Calling
`string["description"] = ""` uses Ruby’s `String#[]=` method which finds
the substring `description` and replaces it with `""`, corrupting the
JSON but leaving it parseable with a `nil` description field instead of
`""`.
2026-02-13 11:55:53 +01:00
Natalie Tay 7691be669a DEV: Setup backend for dedicated tag settings page (#37804)
This commit adds the backend for a dedicated tag settings page (part of
#37170 split, since the PR is huge). The frontend will follow in a
separate PR.

New settings and controller actions on `TagsController` allow fetching
and updating tag settings via `/tag/:id/settings.json`. Updates are
handled by `TagSettingsUpdater` in transaction.

Some details
- TagSettingsSerializer returns raw (unlocalized) tag and synonym names
for editing
  - Tag renames are logged via StaffActionLogger
- Tag#ensure_slug now respects explicit slug changes, allowing
independent slug editing
2026-02-13 17:14:49 +08:00
Loïc Guitaut bcf33a2901 DEV: Refactor category hierarchical search (#37609)
The monolithic `CategoryHierarchicalSearch` service mixed query
building, eager loading, and pagination logic in a single class. The
query was a large raw SQL string built through conditional string
concatenation. Fragments like `#{matches_sql}`, `#{only_ids_sql}`,
`#{except_ids_sql}` were stitched together, with LIMIT/OFFSET appended
via ternary interpolation and named placeholders passed through a
manually assembled hash. This made the query fragile and hard to follow.

Break it into focused, single-responsibility classes under the
`Category::` namespace:

- `Category::HierarchicalSearch` — service orchestrator using
`Service::Base`, with a contract that owns pagination logic (page
validation, limit/offset computation)
- `Category::Query::HierarchicalSearch` — query object that uses
ActiveRecord's interface where it naturally fits (`.where()` with
parameter binding, `.limit()`, `.offset()`, `.with()`, `.joins()`) and
isolates the genuinely complex SQL (recursive CTEs, term matching) into
small named methods rather than a monolithic heredoc
- `Category::Action::EagerLoadAssociations` — extracted eager loading
into a reusable `Service::ActionBase`

The controller is simplified to a single
`Category::HierarchicalSearch.call(service_params)` call with proper
`on_success` / `on_failed_contract` / `on_failure` handling, replacing
manual param transformation and direct result access.

Specs are rewritten to test each class in isolation: the service spec
stubs its collaborators to verify orchestration, the query spec
exercises actual SQL behavior, and the action spec verifies preloading.

Service structure and spec patterns follow the [Discourse service object
guidelines](https://meta.discourse.org/t/using-service-objects-in-discourse/333641)
and the [RSpec Style Guide](https://rspec.rubystyle.guide/).
2026-02-13 09:43:56 +01:00
Joffrey JAFFEUX 24875a45d5 FIX: set_notifications missing guardian topic visibility check (#37786) 2026-02-13 09:09:17 +01:00
Joffrey JAFFEUX f6d86a2718 FIX: bulk action missing report_type format validation (#37787) 2026-02-13 09:08:48 +01:00
Joffrey JAFFEUX 31f5185577 FIX: Add server-side trust level check for delete all posts (#37773)
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.
2026-02-13 09:07:18 +01:00
Sam e75f8b7730 DEV: minor api cleanup, unhandled args (#37795)
if ip address has an invalid action you get the right error
2026-02-13 17:44:10 +11:00
Natalie Tay 91147d8301 FIX: 404s on 'no tags' (#37793)
Currently, selecting "no tags" on the tag dropdown results in a 404 as
the "none" keyword is unhandled. This fixes the issue.



https://github.com/user-attachments/assets/04d1fe66-ee8b-461c-ad14-a0ba8e50cf3c
2026-02-13 13:54:42 +08:00
Penar Musaraj 8eaf428ee2 DEV: Show unauthorized message when OAuth::Unauthorized is hit (#37776) 2026-02-12 13:41:33 -05:00
Joffrey JAFFEUX 50e774b49d FIX: enforces login for create in user emails controller (#37770)
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.
2026-02-12 17:10:43 +01:00
Natalie Tay f939c4ebac FIX: Tag search 500 when content_localization is enabled (#37769)
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
2026-02-12 23:58:03 +08:00
Jarek Radosz f7371d3c44 DEV: Rename theme-compiler's entrypoint and compatModules (#37768)
Extracted from #35477
2026-02-12 16:11:43 +01:00
Natalie Tay a349b9a6c1 FEATURE: Show localized tags (#37044)
Show localized tag names in several places:

- Sidebar
- Tag dropdowns
- Topic list
- Topic view

<img width="1366" height="1005" alt="Screenshot 2026-01-11 at 10 38
23 PM"
src="https://github.com/user-attachments/assets/86a1b9b3-d8a5-481f-a23b-e171b26bd70c"
/>
2026-02-12 18:06:14 +08:00
Natalie Tay 127adedf3f FIX: Redirect to the target tag when synonym is visited (#37672)
There's a bug now when navigating to a tag synonym via the tag dropdown
would show some inconsistencies in the URL and the dropdown. (URL
showing synonym, dropdown showing synonym's target). This is also
evident with the tag banners theme component.

This fix ensures that the user will only see the target tag by making
sure we route to the target immediately from the dropdown, without
triggering a refresh or redirect.
2026-02-12 17:33:02 +08:00
Régis Hanol 6355a0100f FIX: Exclude watched-words and censored from ProseMirror markdown-it engine (#37687)
Watched Words (Replace) caused recursive nesting when editing posts in
the rich editor. Each edit added another layer of replacement text, e.g.
"ETA" → "Estimated Time of Arrival (ETA)" → "Estimated Time of Arrival
(Estimated Time of Arrival (ETA))".

The ProseMirror editor reuses markdown-it to parse raw markdown into its
document tree. This markdown-it engine included the `watched-words` and
`censored` features, which are rendering-only transforms meant for
cooked output. When parsing raw into the ProseMirror AST, these features
replaced text content. On save, ProseMirror serialized the AST back to
markdown with the replacements baked into the new raw — which then got
replaced again on the next cook, causing recursive nesting for `replace`
and permanent content destruction for `censor`.

Adds `watched-words` and `censored` to the omit list for the ProseMirror
markdown-it engine, alongside the already-omitted `onebox`. These
features now only run during server-side cooking where they belong.

Ref - https://meta.discourse.org/t/241735/34
2026-02-12 10:10:47 +01:00
David Battersby 8f33d1c5dd FIX: add alternate email duplicate row (#37673)
Clicking “Resend confirmation email” to an unconfirmed alternate email
address creates a duplicate row that persists after hard refresh.

### The problem

When adding an alternate (secondary) email, within
`lib/email_updater.rb` the value for `add` is true and `old_email`
should be nil — there is no "old email" being replaced, you're just
adding a new one. But the old code always passed @user.email (the
primary email) as old_email in the find_or_initialize_by query.

This caused a duplicate row because:

1. The first time a user adds an alternate email, a row is created with
old_email: nil (lib/email_updater.rb:57 sets it to nil after the
find/initialize).
2. If the user tries the same flow again (e.g. re-requesting
confirmation), find_or_initialize_by searches for a row matching
{user_id: X, old_email: "primary@example.com", new_email:
"alt@example.com"}. That doesn't match the existing row (which has
old_email: nil), so it initializes a new record instead of finding the
existing one.
3. Line 57 then sets old_email = nil on this new record, and when it's
saved, you get a duplicate row — two EmailChangeRequest records with
identical user_id, old_email: nil, and new_email.

### Solution

By passing nil directly in the find_or_initialize_by when add is true,
the query now correctly matches the existing row (old_email: nil), so it
finds the existing request instead of creating a duplicate. The
find_or_initialize_by lookup is now consistent with the value that
actually gets persisted.

Internal ref: /t/129754
2026-02-12 12:46:55 +04:00
Régis Hanol 1a2f9f311e FIX: Use unaccent() for category name/slug search (#37622)
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
2026-02-12 08:43:23 +01:00
Krzysztof Kotlarek f065202715 FIX: Include tag slug in tag groups search response (#37751)
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`.
2026-02-12 15:06:56 +08:00
Sam 84706bcef7 FIX: Don't decrement public_version when destroying hidden revision (#37704)
The original commit fixed an issue where adding hidden tags to a post
would incorrectly increment `public_version`, causing the edit pencil
icon to appear for regular users who couldn't access the hidden
revision. The fix correctly skips incrementing `public_version` for
hidden revisions. However, the commit introduced a bug in
`update_revision` - when a hidden revision is destroyed (e.g., by
reverting changes within the grace period), `public_version` is
incorrectly decremented even though it was never incremented for that
revision.
2026-02-12 10:38:20 +11:00
Sam 84b59e80fb DEV: redeliver_event looks up WebHookEvent globally without scoping to a specific web_hook (#37706)
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
2026-02-12 10:10:25 +11:00
Sam f9fce4712b FEATURE: support target_user_ids for private messages (#37708)
Add `target_user_ids` as an alternative to `target_usernames` when
creating private messages via PostCreator and TopicCreator. The two
options are mutually exclusive and raise ArgumentError if both are
provided.

This avoids username-based lookups where the caller already has user
IDs, and prevents stale references when users rename after a pending
PM is created.

Key changes:
- PostCreator/TopicCreator: accept `target_user_ids`, validate
  mutual exclusivity with `target_usernames`, extract shared
  `add_users_from_scope` in TopicCreator
- Automation PendingPm: migrate from `sender`/`target_usernames`
  string columns to `sender_id`/`target_user_ids` integer columns
  with a backfill migration
- Scriptable::Utils.send_pm: resolve sender upfront, support
  integer sender param, store IDs in pending PMs
2026-02-12 09:21:40 +11:00
Régis Hanol fccae023e0 FIX: Destroy old reply draft when switching to linked topic (#37726)
When a user replies to a topic and then switches to "Reply as linked
topic" via the composer actions menu, the original reply draft was left
behind as an orphan. This happened because `_replyFromExisting()` called
`closeComposer()` which only nullifies the model without destroying the
draft. The new linked topic composer then opens with a different draft
key (`new_topic_<timestamp>`), leaving the old reply draft
(`topic_<id>`) persisted in the database.

Fix this by calling `destroyDraft()` before closing and reopening the
composer in `_replyFromExisting()`. This ensures the old draft is
cleaned up when switching composer actions, regardless of whether the
draft key changes.

https://meta.discourse.org/t/394431
2026-02-11 18:23:19 +01:00
Régis Hanol c63fc5b53d FIX: Use placeholder for "everyone" group name in category permissions (#37724)
Automatic group names are translated to the forum's default locale (e.g.
"jeder" in German). The category permission warning messages and
inherited permission tooltips were hardcoding the English "everyone"
name, which created a mismatch when the admin UI showed the localized
group name.

Replace the hardcoded "everyone" in the four affected translation
strings with a `%{everyone_group}` placeholder and pass the actual group
name from the permission data at each call site.

https://meta.discourse.org/t/395809
2026-02-11 18:23:00 +01:00
Régis Hanol cf20b1c430 FIX: Send notification emails for staff-coloured posts (#37712)
When a staff member applies "Staff Colour" to a post, it changes
`post_type` from `regular` (1) to `moderator_action` (2). The
`NotificationEmailer` only sent emails for `regular` and `whisper` post
types, so staff-coloured posts silently skipped email delivery. The same
issue affected `PostAlerter`'s `@here` mention expansion which also
filtered on post type.

This is safe to fix because the only two places that needed updating are
`NotificationEmailer::EMAILABLE_POST_TYPES` and
`PostAlerter#expand_here_mention`. System-generated moderator posts
(close/split/merge) never reach this code — `PostJobsEnqueuer` skips
them via `skip_after_create?`, so they never generate the notification
types that flow through `NotificationEmailer`. Similarly, system
moderator posts don't contain `@here` mentions, so adding
`moderator_action` to `expand_here_mention` is equally safe.

https://meta.discourse.org/t/370857
2026-02-11 13:26:27 +01:00
Joffrey JAFFEUX 6f14e93e09 FIX: Missing requires_login — authentication relies solely on guardian call (#37716)
## Summary

`ExportCsvController` lacks `requires_login`, causing anonymous requests
to `POST /export_csv/export_entity.json` to receive a misleading 422
"rate limit" error instead of a proper 403 "not logged in" response.

Guardians were blocking any exploit so there was no security exploit.

## Source

- Patch Triage: https://patch.discourse.org/patch-triage/298
- Original Commit:
https://github.com/discourse/discourse/blob/main/app/controllers/export_csv_controller.rb

---

🤖 Generated via [Patch Triage](https://patch.discourse.org/patch-triage)
2026-02-11 12:25:58 +01:00
Joffrey JAFFEUX 4a2f917d33 FIX: update and create_multiple actions missing from requires_login (#37715)
## Summary

`update` and `create_multiple` actions in `InvitesController` were
missing from the `requires_login only:` list, allowing unauthenticated
requests to bypass the `ensure_logged_in` check and reach the action
body.

It's not marked as a security commit as guardians were preventing any
exploit.

## Source

- Patch Triage: https://patch.discourse.org/patch-triage/304
- Original Commit:
https://github.com/discourse/discourse/blob/main/app/controllers/invites_controller.rb

---

🤖 Generated via [Patch Triage](https://patch.discourse.org/patch-triage)
2026-02-11 12:21:32 +01:00
Régis Hanol 9d4475ae5f FEATURE: Add FormKit TagChooser control (#37680)
The category tags page and webhooks form were using `@form.Container`
and `field.Custom` to wrap the select-kit TagChooser component. These
wrappers are simple visual containers that don't participate in
FormKit's field lifecycle — they silently ignore props like `@optional`
and don't support validation, error display, or consistent field
styling.

This commit introduces a proper `field.TagChooser` FormKit control that
wraps the select-kit TagChooser, mapping `@field.value` to `@tags` and
`@field.set` to `@onChange`. It follows the same pattern as the existing
`field.Icon` control.

Changes:
- New `fk/control/tag-chooser.gjs` with pass-through args for
TagChooser-specific options (`@everyTag`, `@excludeSynonyms`, etc.)
- Register the control in `fk/field.gjs` and add pass-through args to
`fk/control-wrapper.gjs`
- Convert `upsert-category/tags.gjs` allowed_tags from Container to
`field.TagChooser`
- Convert `webhooks-form.gjs` tag_names from `field.Custom` to
`field.TagChooser`
- Add `tag-chooser` support to the FormKit Ruby page object for system
specs
- Update simplified_category_creation_spec to use FormKit page objects
- Add integration tests and styleguide example
- Add webhook tag filter system spec

Ref - t/174117
2026-02-11 11:42:33 +01:00
Joffrey JAFFEUX b3242f6852 FIX: correct Discourse.InvalidParameters usage (#37690)
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.
2026-02-11 10:28:12 +01:00
Alan Guo Xiang Tan 92b6c95f39 FIX: Set tagging_directive to "REPLACE" in S3Helper#copy (#37710)
What is the problem?

When `s3_enable_access_control_tags` is enabled, S3 uploads are tagged
with a configurable access control tag (default key: `discourse:acl`) to
indicate visibility. During multipart upload completion and file copies,
`S3Helper#copy` receives a `tagging` option with the desired tags for
the destination object. However, AWS S3 defaults `tagging_directive` to
"COPY", which preserves the source object's tags instead of applying the
new ones. This causes uploads to retain incorrect access control tags
after being copied.

What is the solution?

Set `tagging_directive: "REPLACE"` in `S3Helper#copy` whenever
`options[:tagging]` is present. This mirrors the existing
`metadata_directive: "REPLACE"` pattern already used for metadata and
tells the S3 API to apply the provided tags to the destination object.
2026-02-11 15:10:00 +08:00
Martin Brennan 9a250d8dd1 FIX: Preserve explicit hidden state when depends_behavior is hidden (#37707)
## Summary

The original commit introduced `depends_behavior: :hidden` for site
settings, allowing settings to be automatically hidden when their
`depends_on` dependencies are false. However, there was a logic bug
where settings that were explicitly marked as `hidden: true` could
become visible if their `depends_on` settings were all true, because the
code overwrote the `is_hidden` flag instead of using OR logic.

## Source

- Patch Triage: https://patch.discourse.org/patch-triage/53
- Original Commit: https://github.com/discourse/discourse/commit/60259dd

---

🤖 Generated via [Patch Triage](https://patch.discourse.org/patch-triage)
2026-02-11 16:40:12 +10:00
Natalie TayandKrzysztof Kotlarek 737577e8b4 DEV: Move canonical tag routes to /tag/slug/id keeping /tag/name support (#37055)
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>
2026-02-11 10:21:19 +08:00
Régis Hanol fc6f018cf8 FIX: Show warning when bulk category change fails due to tag restrictions (#37570)
Previously, bulk moving topics to a different category would silently
fail when topics had tags not allowed in the destination category. The
operation reported success but topics remained in the original category.

This change:
- Unifies the category change code path to always use
`first_post.revise()` which validates tag restrictions via
`DiscourseTagging.validate_category_tags()`
- Tracks the count of topics that couldn't be moved
- Shows a warning toast indicating how many topics failed to move

Users now receive clear feedback when some topics cannot be moved due to
tag restrictions between categories.

Note: Moving topics works correctly when:
- Both categories share the same tag group
- The destination category has `allow_global_tags: true` and the topic
has unrestricted (global) tags
2026-02-10 19:50:13 +01:00
Régis Hanol 4acd9c6753 FIX: Custom groups save button not clearing on admin user page (#37625)
The approve/reject buttons for custom group changes on the admin user
page would remain visible after saving, even though the changes were
persisted successfully. Removing a group also failed to update the UI.

Three issues in the reactivity chain caused this:

1. The `@filter` computed macros (classic Ember) on the AdminUser model
did not react to `@trackedArray` mutations. Replaced `customGroups` and
`automaticGroups` with native getters so they participate in Glimmer
autotracking.

2. The controller's `groupAdded()` and `groupRemoved()` wrappers did not
return their promise chains, so `await` in `saveCustomGroups()` resolved
immediately before the API calls completed and before the groups array
was updated.

3. The controller's `customGroupIds`, `customGroupsDirty`, and
`automaticGroups` used `@discourseComputed` which could not track
changes from native getters on the model. Converted these to native
getters and made `customGroupIdsBuffer` `@tracked`.

Additionally, `saveCustomGroups()` now awaits each operation
sequentially and calls `resetCustomGroups()` on completion to sync the
buffer with the updated group list, clearing the dirty state.

Note: only the properties involved in the groups reactivity chain were
converted to modern Glimmer patterns. The rest of the controller and
model still use classic Ember (`@discourseComputed`, `this.set`, etc.)
and can be modernized separately.

Ref - https://meta.discourse.org/t/395210
2026-02-10 19:49:04 +01:00
Régis Hanol af1b9ede9d FIX: improve badge granter resilience (#37674)
Two issues were found in BadgeGranter that could prevent badges from
being granted.

First, `Array#compact!` returns `nil` when no elements are removed. For
PostAction triggers, `[post_id, related_post_id].compact!` returns `nil`
when `related_post_id` is present (nothing to compact), causing the
post_ids payload to be silently lost. Using `compact` instead always
returns the array.

Second, `process_queue!` had no error isolation between badges. A single
badge with a broken SQL query would raise an exception and abort
processing of all remaining badges in the queue. Since queue items are
already popped from Redis at that point, they are lost. Each badge is
now rescued individually so one failure doesn't block the rest.

https://meta.discourse.org/t/394444
2026-02-10 16:31:32 +01:00
Joffrey JAFFEUX 1d2d2f04c9 FIX: remove_bookmarks missing from requires_login (#37676)
## Summary

The `remove_bookmarks` action in `TopicsController` was missing from the
`requires_login` list, allowing anonymous users to hit the endpoint and
trigger a 500 error (NoMethodError on nil) instead of receiving a proper
403 response. There's no security concern as we would after check for a
current_user, but it's much cleaner to fail early with a clean error.

## Source

- Patch Triage: https://patch.discourse.org/patch-triage/203
- Original Commit:
https://github.com/discourse/discourse/blob/main/app/controllers/topics_controller.rb

---

🤖 Generated via [Patch Triage](https://patch.discourse.org/patch-triage)
2026-02-10 15:56:07 +01:00
Joffrey JAFFEUX dc51fcabcb FIX: raise 404 when sidebar section doesn't exist (#37675)
## Summary

`SidebarSectionsController#update` and `#destroy` return 403 Forbidden
instead of 404 Not Found when given a non-existent section ID.

## Source

- Patch Triage: https://patch.discourse.org/patch-triage/195
- Original Commit:
https://github.com/discourse/discourse/blob/main/app/controllers/sidebar_sections_controller.rb

---

🤖 Generated via [Patch Triage](https://patch.discourse.org/patch-triage)
2026-02-10 15:47:49 +01:00
Natalie Tay 8a79c788a5 FIX: Do not write localized fancy title to db when fancy_title is null (#37668)
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`.
2026-02-10 22:22:07 +08:00
Régis Hanol acd09a8972 FIX: Use post author username as fallback in notification emails (#37659)
When multiple replies are collapsed into a single notification,
`PostAlerter` sets `display_username` to a translated reply count (e.g.,
"7 réponses") for use in the notification dropdown UI. The
`original_username` field always holds the actual poster's username.

However, if `original_username` is ever missing from the notification
data, `notification_email` falls back to `display_username`, which
causes the reply count string to be used as the email sender username.
This leads to errors during email rendering.

This updates the fallback chain in `notification_email` to prefer
`post.user.username` over `display_username`, ensuring a real username
is always used even when `original_username` is absent.

https://meta.discourse.org/t//395420
2026-02-10 14:42:23 +01:00
Joffrey JAFFEUX 9694183f7c FIX: enforces logged in, in badges actions (#37666)
There was no actual security issue due to guardian methods, but it's a
better high level check to ensure user is logged in first.
2026-02-10 12:00:56 +01:00
Régis Hanol ca8fcb032f FIX: show sidebar "New category" option for moderators (#37624)
The sidebar "New category" action and the `/new-category` route were
checking `currentUser.admin` directly, which excluded moderators even
when the `moderators_manage_categories` site setting was enabled.

This is inconsistent with the backend Guardian which already allows
moderators to create categories when that setting is on.

Added `can_create_category` to `CurrentUserSerializer` (delegating to
`Guardian#can_create_category?`) and updated the sidebar component and
the new-category route to use it instead of manual role checks.

Ref - https://meta.discourse.org/t/395441
2026-02-10 11:50:11 +01:00
Régis Hanol 1e9524315e FIX: update bookmark UI when using keyboard shortcut (#37639)
When pressing B then Enter to bookmark a topic, the bookmark icon next
to the topic title and the topic footer bookmark button did not update
until page reload.

The keyboard shortcut path opens the BookmarkModal directly via the
topic controller, bypassing the TopicBookmarkManager that the footer's
BookmarkMenu component relies on. The afterSave callback correctly sets
`topic.bookmarked = true`, but the `topicBookmarkManager` computed
property only depended on `"topic"` (the object identity), so it never
recreated the manager to pick up the new bookmark state.

Changed the dependent key to `"topic.bookmarked"` so the manager is
recreated whenever the bookmarked property changes, regardless of which
code path triggered it.
2026-02-10 11:49:53 +01:00
Joffrey JAFFEUX 51c4266c35 FIX: user_count uses public_send with user-controlled input on SiteSetting (#37660)
## Summary

The `user_count` action in `Admin::SiteSettingsController` uses
`public_send` with user-controlled input, allowing invocation of
arbitrary ActiveRecord methods (like `default_scopes`) on `SiteSetting`
that start with `default_`.

## Source

- Patch Triage: https://patch.discourse.org/patch-triage/240
- Original Commit:
https://github.com/discourse/discourse/blob/main/app/controllers/admin/site_settings_controller.rb

---

🤖 Generated via [Patch Triage](https://patch.discourse.org/patch-triage)
2026-02-10 10:14:15 +01:00
Sam e26bc210a6 FIX: Log Discourse ID setting changes to staff action logs (#37649)
## 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.
2026-02-10 18:18:35 +11:00
Sam 9e2d4f14d9 DEV: Missing requires_login for various actions (#37650)
General hygiene, we should require login from state changing routes and
not allow anon to end up getting an incorrect error message here.
2026-02-10 18:18:20 +11:00
Martin Brennan 8125ffa60a FIX: Stop impersonation session not working with group-based upcoming change (#37655)
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
2026-02-10 16:24:31 +10:00
Martin Brennan f1453e8a17 FEATURE: Filter settings by depends_on and link from upcoming changes (#37560)
This allows us to quickly link from the upcoming changes page
to any related settings, since sometimes after the change is enabled,
more settings may need to be configured. The current need for this
is the `enable_custom_splash_screen` setting.
2026-02-10 13:00:05 +10:00
Martin Brennan 6e8570b0fb DEV: Rename experimental_ upcoming change settings (#37589)
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.
2026-02-10 10:34:37 +10:00
Martin Brennan 18833cf928 FIX: Simplified category Tabs allowed & tag group fields (#37591)
Both the allowed_tags and allowed_tag_groups fields were not
correctly saving in the Tabs form when simplified category creation
was enabled. This commit fixes the issue and adds a spec to cover
it.
2026-02-10 09:21:23 +10:00