100 Commits
Author SHA1 Message Date
Loïc Guitaut 39a8c30207 DEV: Add RSpec matchers for JSON:API resources (#42933)
A resource can now be tested like this:

```ruby
  RSpec.describe DiscourseDataExplorer::QueryResource, type: :resource do
    it { is_expected.to declare_type(:queries) }
    it { is_expected.to expose(:sql).readable_by(admin.guardian).hidden_from(Guardian.new) }
    it { is_expected.to have_one(:user) }
    it { is_expected.to sort_on(:name, :last_run_at, "user.username") }
    it { is_expected.to filter_on(:search) }
    it { is_expected.to paginate(default: 50, max: 100) }
  end
```

It’s heavily inspired by shoulda-matchers.
2026-08-31 12:23:19 +02:00
Loïc Guitaut 61daba9e2a DEV: Serve the data explorer queries as JSON:API (#42859)
The queries endpoint now answers JSON:API at
`/api/data-explorer/queries`. The old endpoint stays for now.

A plugin declares an endpoint in two small files: a resource that says
what the endpoint offers, and a controller that names it.

Two things change for a caller. The author and the groups are
relationships now rather than plain fields, which follows the JSON:API
spec, and paging is by cursor rather than by offset, which is the point
of the profile we apply.

The total row count and the queries that ship with the plugin without
being rows are not covered yet.
2026-08-31 12:23:19 +02:00
Loïc Guitaut f2546353ff DEV: Add JSON:API restricted fields (#42814)
A resource used to show every field it declared to everyone, so there
was no way to serve something like `sql` to admins only.

A field can now declare it:

```ruby
  attribute :sql, readable: ->(guardian) { guardian.is_admin? }
  attribute :notes, readable: ->(guardian, record) { guardian.can_edit?(record) }
```

When the rule only looks at the user, the answer is the same for the
whole request, so the column is never selected. When it also looks at
the record, the column has to be read and the decision happens row by
row.

A field the user can’t see is left out, with no error (and requesting it
by name does not change that).

Relationships take the same option, but only in the user form. A rule
that looks at a record raises when the resource loads: there, "the
record" could mean the owner row or each related row, and a related
resource already scopes its own.
2026-08-31 12:23:18 +02:00
Loïc Guitaut 1293c7302a DEV: Add the JSON:API cursor pagination profile (#42805)
The JSON:API Kit pages by cursor and that’s a JSON:API profile.

The specification requires the profile in the `Content-Type` header. It
also recommends it in the top-level `self` link. Every response now
carries both.
2026-08-31 12:23:18 +02:00
Loïc Guitaut 228ab8f7e0 DEV: Add a JSON:API Kit base controller (#42793)
This PR wires the Kit’s document generation into a base controller. It
inherits `ApplicationController`, so an endpoint keeps authentication,
API keys and rate limiting.

It also negotiates as the specification requires. It answers 415 for a
media type parameter we do not support. It answers 406 when an `Accept`
header allows nothing we send. Every response carries the JSON:API media
type.

The default format is JSON: an unexpected error becomes a JSON:API error
document. An error Discourse’s core handles will be served as JSON
instead of an HTML page.
2026-08-31 12:23:17 +02:00
Loïc Guitaut 1a8f6b1f17 DEV: Convert JSON:API parameters in the contract (#42789)
A client sends `sort=-created_at` but `Resource` takes `sort: {
created_at: :desc }`. The contract already validates everything, so it’s
logical for it to also convert values. That’s necessary for wiring the
kit in a controller.

A caller that already passes the kit's own parameters gets them back
unchanged, so nothing below the contract had to move.
2026-08-31 12:23:17 +02:00
Loïc Guitaut 9611f9a6fc DEV: JSON:API Kit core framework (#42306)
Discourse's API grew one endpoint at a time, and each endpoint response
is a custom one. The JSON:API Kit relies on the JSON:API 1.1
specification, so requests and responses are standardized.

This commit adds the core framework and nothing else. No route points at
it and no existing endpoint changes.

Integration specs live in `spec/integration/json_api_kit` and cover the
various JSON:API concepts. Each example assert a rendered document. This
is a good entry point for reviewers.

```ruby
class TopicResource < JsonApiKit::Resource
  model Topic
  type :topics

  attribute :title
  attribute :created_at

  has_one :user, resource: UserResource
  has_many :posts, resource: PostResource
  includes "user.groups"

  sort :created_at
  sort :title
  default_sort created_at: :desc

  filter :title
  anchor :id
  page default: 20, max: 100

  scope { |guardian| Topic.secured(guardian) }
end
```
## What works

- Documents: collections and a single record both render `data`,
`included` and `links`. Every resource object carries its `type`, `id`,
`attributes`, `relationships`, a `self` link, and its own cursor under
`meta`.
- Sparse fieldsets: a client asks for the fields it wants per type, and
the query returns those columns only.
- Related resources: a client provides a path, and the document returns
every record of that path once (no duplicate).
- Sorting: a client provides a sort the resource declares, with a
direction.
- Filtering: a client provides a filter the resource declares, with a
value or a list of values.
- Pagination: every page is read from a cursor, which follows the cursor
pagination profile at
https://jsonapi.org/profiles/ethanresnick/cursor-pagination. A
collection links to `prev` and `next`, and every row carries the cursor
that reads the page from it. The kit adds one extension to that profile:
a page centred on a row
(called an anchor).
- Authorization: a resource declares its scope, and the scope reads the
guardian. A record the scope hides answers 404, and a related record it
hides renders as an empty relationship.
- Errors: a bad request renders `errors`, and each error carries a
`status`, a `title`, a `detail` a person can act on, and the
`source.parameter` that caused it.

## What’s not working yet

- Controller/endpoint: nothing is wired yet, so it can’t serve a real
request from an endpoint.
- Fields: no restricted fields, type, description or example yet.

## What’s next

The first real endpoint, which turns these parts into an end-to-end
path.
2026-08-31 12:23:16 +02:00
Loïc Guitaut af32b31939 DEV: Fix some services raising in model steps 2026-08-28 15:42:57 +02:00
Loïc Guitaut 4cf8388877 DEV: Forward options to nested contracts (#42786) 2026-08-21 11:07:07 +02:00
Loïc Guitaut 47b32ca262 DEV: Make GHSA comparisons case-insensitive 2026-07-28 18:08:16 +02:00
Loïc Guitaut 01d1e02e5b DEV: Don’t include body in security fixes 2026-07-28 16:00:56 +02:00
Loïc Guitaut f92a035936 DEV: Fix isolation bugs in service framework's each step (#40311)
Three independent issues in `each`'s isolation, each surfacing as soon
as the step is used outside the simplest shapes:

1. Nested `each` blocks crashed inside `with_isolation`'s `ensure`. The
isolation kept its snapshot in a single slot that the inner call would
overwrite then null, so the outer's cleanup ran against `nil`. The
snapshot now lives on a stack, making isolation re-entrant.

2. ActiveRecord models in the context lost their primary key inside an
`each` block. The snapshot used `deep_dup`, which recurses into AR
objects via `dup` and AR's `dup` returns an unpersisted copy with `id ==
nil`. Basic shapes like `model :user; each :things do ... end` silently
swapped the real user for a useless ghost. The deep copy was originally
an attempt at mutation isolation for collections, but that contract was
never documented, its supporting spec was removed before the original PR
merged, and the rest of the framework already lets steps mutate what
they receive. A shallow dup matches the documented "variables set inside
the loop don't leak" guarantee and stops corrupting models.

3. Non-persisted state leaked between iterations. The whole loop ran
inside one isolation, so iteration N could read scratch values iteration
N-1 had left around. Each iteration now gets its own isolation. Steps
inside the same iteration still share state freely (step 2 can act on
what step 1 produced); only cross-iteration carry-over now requires
`persist:`, which makes that dependency visible at the `each`
declaration.
2026-05-27 09:47:34 +02:00
Loïc Guitaut 9772faa370 DEV: Fail fast on unknown upcoming change keys (#39800)
Asking `UpcomingChanges.enabled?` about a key that isn't registered used
to crash deep inside `meets_or_exceeds_status?` with a confusing
`NoMethodError (undefined method '>=' for nil)`. That made stale callers
(e.g. controllers still gating on a key whose `upcoming_change` metadata
has been removed) hard to trace back from a 500.

Raise an `ArgumentError` at the public entry point instead, so the bug
surfaces with a clear "Unknown upcoming change: X" message and points
straight at the offending caller.
2026-05-07 10:50:12 +10:00
Loïc Guitaut 2501c6cd1c FIX: Form templates page returning 500 error (#39799)
The `enable_form_templates` site setting was recently demoted from an
upcoming change to a regular site setting, but the controllers were
still gating access through `UpcomingChanges.enabled_for_user?`. Without
the upcoming change metadata, that path crashed with `NoMethodError
(undefined method '>=' for nil)` whenever the setting sat at its default
value, taking the admin Form Templates page down with it.

Switch the two controllers to a plain
`SiteSetting.enable_form_templates` check, and drop the redundant
`before` hooks from the request specs so they actually exercise the
default-value path that broke in production.
2026-05-06 18:53:52 +02:00
Loïc Guitaut 3ed866a6c9 DEV: Add each step to service framework for collection iteration (#38759)
Services that process collections (bulk delete, bulk create, etc.)
currently require manual iteration inside a `step`, losing the
framework's built-in error handling and inspection. This adds a
first-class `each` step that brings the full service DSL inside the
iteration loop.

```ruby
  each :users do
    policy :can_delete
    step :destroy
  end
```

Each iteration receives the singularized item name (`user:`) and
`index:` as keyword arguments. If any nested step fails, iteration stops
and the failing item/index remain in context for error reporting.
Existing matchers (`fail_a_policy`, `fail_a_step`, etc.) work inside
each blocks.

A `persist:` option allows values to accumulate across iterations and
survive the loop's variable isolation:

```ruby
  each :tag_names, persist: { results: -> { { created: [], failed: [] } } } do
    step :create_tag
  end
  # context[:results] available after the loop
```

The steps inspector displays iteration progress ((3/3) on success, (1/3)
when failing at the first item, (empty collection) with ⏭️ when
skipped).
2026-04-03 09:44:03 +02:00
Loïc Guitaut e59401f573 DEV: Use Pitchfork instead of Unicorn in docs (#39041) 2026-04-01 18:00:42 +02:00
Loïc Guitaut 6b243ffdfd DEV: Remove Unicorn web server in favor of Pitchfork (#39032)
Pitchfork has been the default web server for some time now. This
removes Unicorn entirely to simplify the codebase and unblock future
improvements (like Rack 3).

Notable changes beyond the straightforward removal:

- `Discourse.after_unicorn_worker_fork` →
`Discourse.apply_worker_db_variables_overrides`: renamed and wired into
pitchfork.conf.rb's `after_worker_fork`. This actually *fixes*
per-worker DB variable overrides (`unicorn_worker_db_variables_*`) which
were never called under Pitchfork.
- `bin/ember-cli`: `--unicorn` flag renamed to `--server` (`-u` kept).
- `lib/demon/sidekiq.rb`: removed Unicorn-specific USR1/USR2 signal
handlers and `reopen_logs` (called `Unicorn::Util.reopen_logs`), which
were already dead code under Pitchfork.

Intentionally kept unchanged:
- `config/unicorn_launcher` (used by Docker images, separate effort)
- `docker_manager` plugin (separate repo)
- `UNICORN_*` env vars (renaming deferred)
- Rack < 3 constraint (separate PR)
2026-04-01 15:04:59 +02:00
Loïc Guitaut 11f1054d0d DEV: Fix pitchfork stalling on shutdown when using bin/ember-cli -u (#38916)
When pressing Ctrl+C with `bin/ember-cli -u`, the server could appear
stuck for up to 60 seconds and stop responding to further Ctrl+C
attempts, requiring `kill -9` to terminate.

This was most commonly triggered after switching git branches, which
could leave workers stuck in native code (autoloading, V8 compilation,
etc.), making the slow shutdown path much more noticeable.

The root cause was a signal race: when Ctrl+C sends SIGINT to the
process group, ember-cli's monitoring thread would also send SIGTERM to
the pitchfork supervisor after detecting ember died. This TERM arrived
at the pitchfork monitor while it was already performing a hard shutdown
(from INT), causing it to abort the hard shutdown and fall back to a
slow graceful shutdown with a 60-second timeout. Combined with both
bin/ember-cli and bin/pitchfork unconditionally swallowing all
subsequent SIGINTs, the user had no way to interrupt.

Fixes:
- bin/ember-cli: track whether SIGINT was received and skip sending TERM
to the server in that case (it already got INT from the terminal)
- bin/ember-cli, bin/pitchfork, bin/unicorn: add signal escalation so
repeated Ctrl+C can force-kill children as a last resort
2026-03-27 13:48:58 +01:00
Loïc Guitaut 3837000fe8 DEV: Set gpgsign=false at repo init in release specs (#38535)
Prevents interactive GPG prompts (FaceID, TouchID, passphrase dialogs)
during test execution regardless of the developer's local Git config.
2026-03-12 15:01:52 +01:00
Loïc Guitaut f77f59a5c1 DEV: Bump version automatically when cutting release branches (#38504)
During recent refactoring, we lost the ability to bump versions on
release branches. Rather than restoring the separate workflow and rake
task that existed before, this integrates version bumping directly into
the existing release automation:

1. When `release:maybe_cut_branch` creates a new release branch, it now
strips the `-latest` suffix and commits the release version directly
(e.g., `2025.1.0-latest` → `2025.1.0`).

2. When `release:stage_security_fixes` targets a release branch, it now
bumps the patch version automatically (e.g., `2025.6.0` → `2025.6.1`).

For non-security patch releases, the existing workflow handles tagging
automatically when commits are pushed to release branches with a
manually bumped version.
2026-03-12 12:05:28 +01:00
Loïc Guitaut c11c6c3ba2 FIX: Filter hidden posts from raw topic markdown endpoint (#38237)
The `/raw/:topic_id` endpoint was exposing hidden posts to users who
shouldn't see them, while `/raw/:topic_id/:post_number` correctly
filtered them via `guardian.can_see?`.

This inconsistency meant anonymous users and regular users could see
hidden post content when fetching a whole topic as markdown.

Also refactors `markdown_num` into smaller private methods for clarity.
2026-03-04 12:48:22 +01:00
Loïc Guitaut 9d144cac4b DEV: Bump latest branch version on security fixes (#37774)
When security fixes are staged for the main branch via the
`stage_security_fixes` task, the development version is now
automatically incremented (e.g. `2026.2.0-latest` to
`2026.2.0-latest.1`). This ensures each security fix batch gets its own
tagged version, allowing docker_manager to detect that installations on
`latest` need a critical update.

To support this, a new `ReleaseUtils::Version` value object encapsulates
version parsing, comparison, and manipulation logic that was previously
done through ad-hoc string splitting and `Gem::Version` comparisons in
release.rake. It understands Discourse's versioning scheme
(major.minor.patch-pre.revision) and provides methods like
`#next_revision`, `#same_development_cycle?`, and
`#next_development_cycle`.

All existing rake tasks have been refactored to use `Version` objects,
the unused `parse_current_version` helper and
`prepare_next_version_branch` task have been removed, and the release
specs have been rewritten following RSpec style guide conventions.
2026-02-26 11:10:55 +01:00
Loïc Guitaut 08df9a7ebb DEV: Fix the lock step when using contracts (#38019)
Recently, the `lock` step from our Ruby service framework has been
extended to be able to work with keys from the global context when the
key isn’t present in the `params`.

The implementation is flawed because we usually get a contract object in
the `params` key of the context, and currently we call
`public_send(key)` on it. This actually raises an error when the key
doesn’t exist on the contract.

The fix is rather simple, we just use `try` instead, that way if the
method doesn’t exist, it returns `nil`, allowing us to get the key from
the global context as expected.
2026-02-24 12:30:38 +01:00
Loïc Guitaut 6759ad71ca DEV: Extract step classes from Service::Base into individual files (#37956)
The `base.rb` file (640+ lines) contained 9 step classes, the `Context`
class, and the `StepsHelpers` DSL module alongside the core concern.
Each is now in its own file under `lib/service/base/`, following the
existing `lib/service.rb` + `lib/service/*.rb` pattern.

Also:
- Update stale inline documentation in `lib/service.rb`
- Use `NotImplementedError` instead of generic raise in `PolicyBase` and
`ActionBase`
- Move YARD DSL docs to a `@!parse` block at the top of `base.rb`
2026-02-23 09:49:37 +01:00
Loïc Guitaut 9290012adf DEV: Allow lock step to resolve keys from service context (#37952)
Previously, the `lock` step could only build its lock name from values
present in `params`. This made it impossible to lock on values derived
from earlier steps, such as a model fetched from the database.

The lock name resolution now falls back to the service context when a
key isn't found in `params`. When the resolved value is a model
(responds to `id`), its id is used instead of the object itself,
producing a meaningful lock name.

This is fully backward compatible: existing callers that pass param keys
still resolve from `params` first.
2026-02-20 17:00:07 +01:00
Loïc Guitaut d02ae397d5 DEV: Disable Pitchfork setpgid in dev/test for debugger support (#37948)
Pitchfork defaults `setpgid` to `true`, which calls
`Process.setpgid(pid, pid)` on each forked worker, moving it into its
own process group. This detaches the worker from the terminal's
foreground process group, so any attempt to read STDIN (e.g.
`binding.pry`) fails with:

    Error: Input/output error @ io_getpartial - <STDIN>

Unicorn never called `setpgid`, which is why `binding.pry` worked there.
Setting `setpgid false` in non-production environments restores that
behavior and allows interactive debuggers to function.
2026-02-20 15:00:36 +01:00
Loïc Guitaut 7cf341eea7 DEV: Bump required Ruby version to 3.4 (#37819)
Now that our base images ship Ruby 3.4, our Gemfile should require it as
the minimum allowed version.
2026-02-13 17:34:47 +01:00
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
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
Loïc Guitaut 7324b327cd DEV: Allow pitchfork workers to take more time to boot (#37721)
On constrained hardware/environments, the splay we added for Pitchfork’s
workers isn’t always enough and they hit the spawn timeout which is 10
seconds by default.

This changes the default to 60 seconds, which should give plenty of time
for booting even in the worst conditions. Unicorn didn’t have this
timeout, explaining why we never saw this phenomenon before.

The `APP_SERVER_SPAWN_TIMEOUT` env var can be used to lower or increase
the timeout value.
2026-02-11 15:10:12 +01:00
Loïc Guitaut a49b2dc46c DEV: Enable Pitchfork by default (#37679)
This changes the logic from opt-in to opt-out regarding Pitchfork.

Now, instead of having to explicitly set `RUN_PITCHFORK` to `1`, it has
to be set to `0` to switch back to Unicorn.
2026-02-11 11:52:39 +01:00
Loïc Guitaut 02b3407b5a FIX: Complete protection for potentially illegal reviewables on post deletion (#37536)
This is a follow-up to fd04f690.

The original commit only protected potentially illegal reviewables from
being auto-approved in one code path (direct post deletion). However,
when deleting a post through a reviewable action with
`notify_users_after_responses_deleted_on_flagged_post` enabled, the code
would still call `#ignore` on any flagged reply posts without checking
`#potentially_illegal?`.
2026-02-05 09:42:52 +01:00
Loïc Guitaut 0726bf70a3 DEV: Throttle forking of Pitchfork workers (#37487)
This is a follow-up to d50ec29f92.

Here, we’re applying what’s been done for Unicorn, as it shows better
results than setting `spawn_timeout` to a higher value. The overall boot
will be a bit slower, but it will put less pressure on the system and
will work better in the end.
2026-02-04 09:50:58 +01:00
Loïc Guitaut d50ec29f92 DEV: Give more time to pitchfork workers to spawn (#37405)
When deploying many workers, their startup can be relatively slow
because of the warm up we’re doing in the `after_work` hook.

The default time out when spawning a worker is 10 seconds, which is
usually fine, but sometimes more time is needed.

This patch sets `spawn_timeout` to the same value as `timeout`.
2026-02-02 12:10:56 +01:00
Loïc Guitaut aaadd98603 DEV: Allow to filter backtrace in exceptions caught in services (#37382) 2026-01-30 07:59:13 +10:00
Loïc Guitaut 3ac5a0fa5d FIX: Allow to delete first posts with bulk destroy (#37180)
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`.
2026-01-19 11:39:38 +01:00
Loïc Guitaut caaa8f9c9e FIX: Don't escape HTML entities twice in oneboxes (#37141)
Sometimes, HTML entities can be escaped twice, typically when getting
sanitized data from our `Onebox::OpenGraph` class then providing that
value to a template. We’re using the Mustache gem to process the Onebox
templates, and it will automatically escape HTML entities. This is
usually not a problem, but it is for things like ampersands. For
example, if the value we provide to the template is `&amp;`, then
Mustache will convert it to `&amp;amp;`.

This patch fixes that behavior by decoding the result of the sanitization
we apply in `Onebox::OpenGraph`. That way, templates will get `&`
instead of `&amp;`, thus there won’t be any double escaping.
2026-01-16 11:40:18 +01:00
Loïc Guitaut fd04f690b1 FIX: Don’t automatically approve illegal reviewables on post deletion (#37015)
Currently, when a flagged post is deleted directly from the topic, the
reviewable is automatically approved. While this seems sensible in most
cases, when the post is flagged as illegal, it can become a hassle for
some forum operators (that have to report things because of the DSA, for
example) when the post wasn’t really illegal.
2026-01-12 12:21:48 +01:00
Loïc Guitaut d3e9b019c1 DEV: Make changes for docker_manager Pitchfork compatibility (#36456)
This brings the necessary changes to allow the `docker_manager` plugin
to work properly with Pitchfork (see
https://github.com/discourse/docker_manager/pull/293).

Three things are needed:
- Tell our MessageBus config to not return a 429 if a request takes more
than 100 ms while the Pitchfork server is restarting.
- Update the Pitchfork config to allow it to reuse its own port (this
allows us to start a new Pitchfork server while the old one is still
running)
- Update the `unicorn_launcher` script to restart the Pitchfork server
when it receives a USR2/HUP signal. This way, it doesn’t complicate
things too much.
2025-12-18 14:22:39 +01:00
Loïc Guitaut 89be127ced DEV: Handle nested attributes in contracts (#36348)
This change adds the ability to validate more complex structures in the
Ruby service contracts.

Contracts were limited to flat structures, which is fine most of the
time, but it can become tedious when managing lots of attributes.

With this new feature, contracts like this one can be defined:
```ruby
attribute :channel_id, :integer

attribute :record, :hash do
  attribute :id, :integer
  attribute :created_at, :datetime
  attribute :enabled, :boolean
end

attribute :user, :hash do
  attribute :username, :string
  attribute :age, :integer

  validates :username, presence: true
end

attribute :items, :array do
  attribute :name, :string

  validates :name, presence: true
end

validates :channel_id, presence: true
```

Two nested types are available: `hash` and `array`.

Each block creates a new contract, meaning coercions, validations and
callbacks are available as usual.
2025-12-12 11:41:48 +01:00
Loïc Guitaut a8b312426b DEV: Add symbol to ActiveModel attribute types (#36420)
`ActiveModel` doesn’t have a `symbol` type by default, but it can be
quite handy for contracts from our service framework.

This way, it’s easy to get a symbol from an attribute. Any non-blank
value will get converted to a symbol by being converted to a string
before. A blank value will return `nil`.
2025-12-03 10:57:44 +01:00
Loïc Guitaut 4791accfa1 FIX: Fallback to upload URLs when SHA1 doesn’t match (#36299)
Currently, when uploads have a different SHA1 in their URL from the one
that has been computed (it can happen with secure uploads), and those
uploads are images, then the cooked post processor won’t link them
properly to their post. The post has them as `Post#uploads` but not as
`Post#image_upload`. This in turn will make the associated topic
thumbnail-less when the post is the first one.

To address the issue, it’s a matter of adding fallbacks to
`CookedPostProcessor#update_post_image` when fetching the uploads. This
is something we already do in
`HasPostUploadReferences#link_post_uploads`.
2025-11-28 12:25:38 +01:00
Loïc Guitaut e8657b4476 DEV: Improve Pitchfork workers timeouts (#36165)
Currently, when a Pitchfork worker times out, we’re logging its
backtrace as a warning. After that, Pitchfork will terminate cleanly the
worker by calling `Process.exit`. This, however, raises a `SystemExit`
exception that is caught by Logster. Since we already have the backtrace
logged, we don’t need to log that exception which is more confusing than
anything else.

This change makes Logster ignore that `SystemExit` exception and raises
the log level to error instead of warning.
2025-11-24 10:49:54 +01:00
Loïc Guitaut f1941ced4a DEV: Dump backtrace when a Pitchfork worker is about to timeout (#36021)
This is something we have for Unicorn, but it hadn’t been backported to
our Pitchfork config.
2025-11-13 15:18:07 +01:00
Loïc Guitaut 7c03bddc95 FIX: Set invited PM users to watch so they receive notifications (#35722)
When a user is invited to an existing private message, they were not
receiving notifications for subsequent messages. This occurred because
`TopicUser` records are created lazily when a user first visits a topic,
but `PostAlerter#notify_pm_users` checks the notification level
immediately when determining who should receive notifications.

This PR explicitly creates a `TopicUser` record with its notification
level set to `watching` immediately after granting PM access, ensuring
the invited user receives notifications for all subsequent messages even
before their first visit.
2025-11-06 11:26:50 +10:00
Loïc Guitaut 22d0bf0396 DEV: Don’t ever dump the DB schema (#35736)
We don’t want to dump the DB schema as it can lead to various problems,
mainly with plugins.

We already have some patches that take care of that, but sometimes a
`db/structure.sql` file is still generated.

The solution is to instruct Rails to never dump that schema, even in the
test and development environments.
2025-10-31 16:14:14 +01:00
Loïc Guitaut a3fd7eb462 DEV: Add ruby-lsp-rspec (#35735)
We already have `ruby-lsp` and `ruby-lsp-rails`, so let’s add
`ruby-lsp-rspec` too, as it helps navigate specs quite a lot.
2025-10-31 12:20:20 +01:00
Loïc Guitaut eba1c8090c FIX: Display a thumbnail for Youtube videos with the classic onebox (#35715)
Currently, a thumbnail is usually generated when generating oneboxes for
Youtube videos. This happens because of the lazy-videos plugins. But
when it’s not enabled or it can’t fetch the metadata as expected, we
fallback on the original Youtube onebox.

That onebox just outputs an iframe and nothing more. This means no
thumbnails will be created for the associated topic.

This patch addresses the issue by outputting an invisible image before the
iframe, thus allowing thumbnails to be created. This makes the user
experience more consistent, whether using the classic onebox or the
lazy-videos one.
2025-10-30 17:00:18 +01:00
Loïc Guitaut cdf3ce59ac DEV: Allow contracts to inherit from another class (#35599)
This change aims to allow contract reusability. Typically when two
services are closely related (like `create`/`update`), a lot of (maybe
even all of them) parameters could be the same.

So instead of having to redefine virtually the same contract twice, with
this change it’s possible to provide a base class for the contract, like
this:
```ruby
params base_class: MyCreationService::Contract
```
2025-10-30 10:05:36 +01:00
Loïc Guitaut 2a7cb3dc00 FIX: Don’t apply callbacks from disabled plugins (#35630)
Currently, when a plugin is disabled, its various callbacks are still
taken into account. This can lead to performance issues.

This patch adds a check to most of the `register_*` methods a plugin can
use, so if the plugin is disabled, those callbacks won’t be applied.
2025-10-30 10:04:17 +01:00
Loïc Guitaut 154224f109 DEV: Add Pitchfork alongside Unicorn (#35370)
This PR adds Pitchfork, as we want to move away from Unicorn ultimately.

Unicorn still boots by default, so there should be no disruption for
anyone.

To use Pitchfork instead of Unicorn, the `RUN_PITCHFORK` environment
variable must be set.
This will make `bin/rails s` and `config/unicorn_launcher` boot
Pitchfork. `unicorn_launcher` was patched because that way we can easily
switch between Unicorn and Pitchfork without having to change too many
things on the infra side.

The upgrader from the `docker_manager` plugin doesn’t work yet with
Pitchfork. This will be addressed in a future PR.
2025-10-24 11:08:23 +02:00
Loïc Guitaut 695533b99c DEV: Add a compact_blank option to the ActiveModel array type (#35476)
Instead of having to clean an array in a contract using a
`before_validation` block, for example, we can now pass `compact_blank:
true` to the attribute, like this:

```ruby
attribute :ids, :array, compact_blank: true
```
2025-10-20 11:33:36 +02:00
Loïc Guitaut 68c0ca00f3 DEV: Display a warning when assets are precompiled in local env (#35323)
When precompiled assets exist, Propshaft will switch to a static mode
and only serve those existing assets. This confuses people on a regular
basis, so this patch addresses that issue by displaying a banner on
STDOUT if precompiled assets exist in a local env (test or dev).
2025-10-10 17:53:32 +02:00
Loïc Guitaut a39785f3dd DEV: Add only_if step to DRSF (#35247)
This patch introduces a new step to the Ruby Service Framework.

`only_if` will execute its block (other steps) only if the provided
condition evaluates to `true`. As for the other steps, the name provided
is the name of the method that will be executed.

```ruby
only_if(:can_update) do
  model :user
  step :update_user
  step :notify
end

private

def can_update(guardian:)
  …
end
```

This step cannot fail and the whole block will be skipped if the method
evaluates to a falsy value.

This is rendered by the steps inspector:

```
[ 1/13] [options] default 
[ 2/13] [model] model 
[ 3/13] [policy] policy 
[ 4/13] [params] default 
[ 5/13] [lock] parameter:other_param 
[ 6/13]   [transaction]
[ 7/13]     [step] in_transaction_step_1 
[ 8/13]     [step] in_transaction_step_2 
[ 9/13] [try]
[10/13]   [step] might_raise 
[11/13] [only_if] condition ⏭️ (condition was not met)
[12/13]   [step] optional_step
[13/13] [step] final_step 
```

The inspector also renders text with colors now.

<img width="419" height="336" alt="Copie d'écran_20251008_162920"
src="https://github.com/user-attachments/assets/add4fd16-076e-4f30-ae1a-3e933494967e"
/>
2025-10-08 17:20:22 +02:00
Loïc Guitaut a9c988a606 DEV: Serialize cookies using MessagePack (#35082)
This PR uses MessagePack instead of JSON for serializing our cookies.

MessagePack is almost as fast as Marshal but without the security
issues. It’s also able to serialize more objects than JSON (like Time,
Symbol, etc.). As it’s a binary format, it takes less space than JSON,
sometimes half less. Finally, MessagePack isn’t Ruby-specific and
implementations exist in every existing language.

Regarding the cookies Discourse is using, we can see a small improvement
on the `_forum_session` one when it’s almost empty (around 2%), but the
more things are put into it, the more we’ll see savings. For the `_t`
cookie, we’re saving around 20% for free.
2025-10-03 11:40:49 +02:00
Loïc Guitaut 0c41ff0680 DEV: Move more data into the server session (#35145)
Now that `ServerSession` can store arbitrary data, we can move some more
data into it.

This patch moves some data related to authentication into it, as
sometimes that kind of data can be pretty big.
2025-10-03 10:20:32 +02:00
Loïc Guitaut 5146a8e399 Revert "DEV: Debug cookie overflows" (#35120)
Reverts discourse/discourse#34639

We understood the main culprit for cookie overflows was storing
`destination_url` in the session, so we don’t really need that debug
code anymore.
2025-10-02 09:55:47 +02:00
Loïc Guitaut 2676c70572 Revert "DEV: Move more data into the server session" (#35115)
Reverts discourse/discourse#35009
2025-10-01 16:44:43 +02:00
Loïc Guitaut 066d3a1abc DEV: Move more data into the server session (#35009)
Now that `ServerSession` can store arbitrary data, we can move some more
data into it.

This PR moves some data related to authentication into it, as sometimes
that kind of data can be pretty big.
2025-10-01 15:00:48 +02:00
Loïc Guitaut 8761d47e26 DEV: Allow ServerSession to store arbitrary data (#34919)
Currently, the server session can only store strings. As we want to move
more things into it (like with a proper session object), we need to be
able to store arbitrary data.

This PR serializes data using Message Pack, as it’s almost as flexible
as Marshal but without the potential security issues.
2025-09-26 10:35:28 +02:00
Loïc Guitaut 2e47ee8a9e DEV: Fix Unicorn reloading (#34980)
Recently we replaced the `pry-byebug` gem by the `debug` one. It broke
the auto-reload mechanism we have in the development environment for
Unicorn.

Indeed, the `debug` gem by default will add an `at_exit` hook that will
wait for all its children to exit. It clashes with our own mechanism.

The workaround is to require `debug/prelude` instead of `debug`: the
`debugger` command and breakpoints will still work but without the hook
being set.
2025-09-25 15:51:02 +02:00
Loïc Guitaut da12368682 DEV: Finish renaming secure_session to server_session 2025-09-23 10:35:02 +02:00
Loïc Guitaut 3fc6511278 FIX: Don’t store return path in the session
Since the session is backed by a cookie, storing too much data will lead
to a cookie overflow error.

A return path can be quite large sometimes, so intead of storing it in
the session, this patch stores it in our server session.
2025-09-19 10:05:20 +02:00
Loïc Guitaut b4e4833d2a DEV: Rename SecureSession to ServerSession
This patch will be followed by
https://github.com/discourse/discourse/pull/34747.

`SecureSession` doesn’t make a lot of sense anymore and can be confusing
as the current cookie store used for the session is actually secure
since it’s encrypted.

Renaming it to `ServerSession` better conveys what it does: providing a
session but on the server side only.

This patch also makes some improvements, like injecting that server
session into Rack-like request objects, allowing the server session to
be available virtually everywhere.
2025-09-18 16:31:03 +02:00
Loïc Guitaut 2d4320895e DEV: Debug cookie overflows
This patch logs what’s in the cookie when there is an overflow, as it
happens sometimes during auth workflows. This should help us better
understand what’s happening.
2025-09-01 09:38:52 +02:00
Loïc Guitaut eedda1f809 DEV: Enable Goldiloader by default
As things are going well with Goldiloader enabled, we can now enable it
by default.
2025-08-26 09:48:56 +02:00
Loïc Guitaut ca81e8a5b2 DEV: Unify ListChannelMessages/ListChannelThreadMessages behaviors
We recently updated `Chat::ListChannelThreadMessages` to take an option
so its `max_page_size` could be configured.

This behavior should be consistent between
`Chat::ListChannelThreadMessages` and `Chat::ListChannelMessages` since
they’re basically doing the same thing.

This patch updates the behavior of the `Chat::ListChannelMessages`
service.
2025-08-25 13:48:02 +02:00
Loïc Guitaut 8dd303826e DEV: Refactor Chat::ListChannelThreadMessages a bit (#33380)
- Introduce a `max_page_size` option, allowing different behavior
between controllers and SDK.
- Improve the contract (validations & helper method).
- Use `model` where possible.
- Extract message existence logic to a dedicated policy, allowing easier
testing.
- Refactor specs to follow current guidelines/best practices.
2025-08-08 14:05:38 +02:00
Loïc Guitaut eb09733391 FIX: Catch possible PG exception from Chat::AutoJoinChannels (#34132)
Currently, it can happen that the `Chat::AutoJoinChannels` service
raises a `PG::UniqueViolation` error. This is probably due to a race
condition. That exception is not rescued, leading to 500s.

This PR wraps the main step inside a `try` block and also inside a
`lock` block.
2025-08-07 12:50:22 +02:00
Loïc Guitaut 1dc7295bba DEV: Add Goldiloader behind a setting (#34006)
This will allow us to try out Goldiloader and see how it performs.
2025-08-04 12:05:02 +02:00
Loïc Guitaut 3aa6842a5c DEV: Pin Rack to < 3 (#33891)
Upgrading Rack to version 3 will break Unicorn. When we don’t use Unicorn anymore, we’ll be able to upgrade to Rack 3.
2025-07-28 11:43:52 +02:00
Loïc Guitaut 039aecae9b DEV: Fix sidekiq requires in test env
When in test env, we can get errors because the "sidekiq/api" file isn’t
properly loaded.
2025-07-22 12:40:57 +02:00
Loïc Guitaut 0eab7daea4 DEV: Upgrade Rails to version 8.0.2
- Migrated from annotate to annotaterb as the former is not maintained
  anymore.
- Dropped our `fast_pluck` patch as the default `pluck` implementation
  seems now faster.
2025-07-22 09:59:44 +02:00
Loïc Guitaut bf08512288 FIX: Don’t create empty event dates in calendar
Currently, there’s an edge case in the calendar plugin: when a recurring
event (typically a daily one) ends on the next day, it can happen that we
create a `event_date` record with no `starts_at` attribute.

This is because `Event#calculate_next_date` doesn’t check the result
from `RRuleGenerator.generate`.

This patch addresses the issue simply by checking the value of
`RRuleGenerator.generate` and returns early if the value is `nil`.
2025-07-17 16:53:14 +02:00
Loïc Guitaut df8428f7a5 FIX: Serialize time objects properly in calendar plugin
Currently, `#event_starts_at` and `#event_ends_at` are not properly
serialized: we just output the string we have in the custom fields,
which is not necessarily a time string JS can deserialize. Until now it
worked by chance.

With Rails 8, the string format changes a bit to include the timezone,
breaking the deserializing process on the JS side.

This patch addresses the issue by converting the value stored in the
custom fields to a proper time object. Then the serializers, when
rendering JSON, will output a proper ISO8601 formatted string.
2025-07-16 16:14:51 +02:00
Loïc Guitaut 3fbb2954cb DEV: Refactor Chat::ListChannelMessages service a bit
- improve the contract a little
- use `model` where possible
- extract message existence logic to a dedicated policy, allowing easier
  testing.
- remove unused code
- refactor specs to follow current guidelines/best practices
2025-06-23 14:18:49 +02:00
Loïc Guitaut 1a9f577044 DEV: Don’t check model validity when no changes have been made
When fetching a model using the `model` step in a service, if that model
is an `ActiveRecord` object, we check if it’s in a valid state. While
this is useful when manipulating the model or when we create a new one,
it’s not the case for a model we just pulled from the DB, as it should
be valid.

In some cases, running the validations can be costly (it can lead to N+1
queries if the model validates associated items for example).

This patch introduces a small optimization by checking if the model has
any pending changes on it, thus requiring validation. If that’s not the
case, we just skip the validation part, as the model should be valid
anyway.
2025-06-20 09:09:33 +02:00
Loïc Guitaut 6e22f8fac8 DEV: Remove generic exception in model step in services
Currently when a model is not found, we raise an `ArgumentError`
exception and that exception is stored in the resulting context object.

However, since we’re also storing unexpected exceptions, this default
exception can pollute the context object when we need to inspect it or
act on it.

This patch addresses that issue by raising a custom exception instead,
and we then discard it from the context object.
2025-06-17 16:12:28 +02:00
Loïc Guitaut f0fcac1243 DEV: Refactor Chat::LookupChannelThreads to follow best practices
- use `model` where possible
- extract threads fetching logic to its own action
- refactor specs to follow current guidelines/best practices
2025-05-22 12:06:54 +02:00
Loïc Guitaut edbd5e08f9 DEV: Refactor StartReply & StopReply services a bit
- Move some data transforming into contracts.
- Add some missing specs.
- Use the `try` step.
- Improve the `model` step a bit by allowing to catch any exception,
  and not only `ArgumentError`. We already had the mechanism to inspect
  which exception was caught.
2025-04-30 11:50:22 +02:00
Loïc Guitaut 21a7f31622 SECURITY: Enforce DM limits properly
When adding people to a DM, the ones already in the channel weren’t
taken into account when checking whether the maximum limit was reached.
2025-04-29 12:06:33 +08:00
Loïc Guitaut 5e9a1a64c7 DEV: Fix the error message from the deprecated icon handler
The string was written as a JS one (using ``), but in Ruby this syntax
tries to execute the string as a command on the host system.
2025-04-09 12:18:14 +02:00
Loïc Guitaut c90544ab44 DEV: Add missing specs to User::BulkDestroy 2025-04-08 11:42:51 +02:00
Loïc Guitaut c96e7aa723 DEV: Fix the enable_current_plugin spec helper
Some plugins are always enabled and don’t have a related site setting.
This patch takes this into account.
2025-04-02 12:30:46 +02:00
Loïc Guitaut 05f533ee0e DEV: Add more granularity to the core features specs shared example 2025-04-01 14:54:11 +02:00
Loïc Guitaut ebed9cd013 DEV: Add a spec helper to upload a theme or a component 2025-03-31 17:28:09 +02:00
Loïc Guitaut 54c38e6163 DEV: Add a helper to enable current plugin in specs 2025-03-28 14:18:03 +01:00
Loïc Guitaut 4f82ceaf39 DEV: Introduce core features system specs for plugins
This patch adds a new shared example to be used as a smoke test in
plugins and themes.

A `skip_examples` argument is available to easily opt-out from a
category of tests.

Example:
```rb
RSpec.describe "Testing core features", type: :system do
  it_behaves_like "having working core features", skip_examples: %i[search login]
end
```
2025-03-27 12:12:01 +01:00
Loïc Guitaut 2ed31fea64 DEV: Upgrade the Redis gem to v5.4 2025-03-19 14:34:00 +01:00
Loïc Guitaut 3dbbb940de DEV: Upgrade Sidekiq to v7.3.9 2025-03-10 15:02:48 +01:00
Loïc Guitaut 4b3044565d DEV: Check Gemfile.lock is up to date on CI 2025-03-04 16:29:50 +01:00
Loïc Guitaut b9dd9c70a5 DEV: Migrate Sidekiq to a dedicated Redis DB
As we’re currently using a namespace for Sidekiq, in order to upgrade to
the latest version, we need to drop it as it’s not supported anymore.

The recommended way is to use a different Redis DB for Sidekiq.

This patch uses a different config for Sidekiq and also takes care of
migrating existing jobs (in queues and the retry and scheduled sets).
2025-03-03 15:42:26 +01:00
Loïc Guitaut dd4cee5fa5 DEV: Enable Bundler checksums
See https://bundler.io/blog/2024/12/19/bundler-v2-6.html.
2025-02-27 14:43:11 +01:00
Loïc Guitaut 140775d505 DEV: Enable RSpec/InstanceVariable rule for models 2025-02-17 10:00:08 +01:00
Loïc Guitaut a4d34d60e3 DEV: Make Ruby services thread-safe
A previous refactor of the `Service::Base::Step` class introduced a
non thread-safe behavior. `#call` mutates instance variables at runtime,
and since a step instance is the same for any given service class, this
can sometimes lead to `context` being the wrong one for the running
service.

This patch makes use of `Concurrent::ThreadLocalVar` to fix the issue.
2025-02-11 11:18:42 +01:00
Loïc Guitaut f057c71fc8 DEV: Follow-up to the lock step for services
This patch adds two things:

1. An outcome matcher (`on_lock_not_acquired`), allowing to react when
   there was a problem with the lock.
2. Compatibility with the steps inspector, allowing to display properly
   the steps of a service containing locks.
2025-02-06 11:38:15 +01:00
Loïc Guitaut 5055a071b8 FIX: Allow to follow non-ASCII canonical links for oneboxes 2025-02-04 15:40:23 +01:00
Loïc Guitaut 133a648d9b DEV: Fix policy classes delegating their #call method in services
There’s currently a bug when using a dedicated class as a policy in
services: if that class delegates its `#call` method (to an underlying
strategy object for example), then an error will be raised saying steps
aren’t allowed to provide default parameters.

This should not happen, and this patch fixes that issue.
2024-12-18 09:59:40 +01:00
Loïc Guitaut 9e9abe0a82 DEV: Unify params access in services
Currently, there are two ways (kind of) for accessing `params` inside a
service:
- when there is no contract or it hasn’t been reached yet, `params` is
  just the hash that was provided to the service. To access a key, you
  have to use the bracket notation `params[:my_key]`.
- when there is a contract and it has been executed successfully,
  `params` now references the contract and the attributes are accessible
  using methods (`params.my_key`).

This patch unifies how `params` exposes its attributes. Now, even if
there is no contract at all in a service, `params` will expose its
attributes through methods, that way things are more consistent.

This patch also makes sure there is always a `params` object available
even when no `params` key is provided to the service (this allows a
contract to fail because its attributes are blank instead of having the
service raising an error because it doesn’t find `params` in its context).
2024-12-13 11:13:18 +01:00
Loïc Guitaut a589b48f9a DEV: Display better output when inspecting service steps
This patch aims to improve the steps inspector output:
- The service class name is displayed at the top.
- Next to each step is displayed the time it took to run said step.
- Steps that didn’t run are hidden.
- `#inspect` automatically outputs the error when it is present.
2024-12-12 15:21:10 +01:00