concerns_on_rails 1.11.1 → 1.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f8d7976f6ba1afbdd29545f992c77fe50f42771828cb01ae14ea4dc194d6f36a
4
- data.tar.gz: d159c7e96f3e430e71aa9d3d18f5ce8d70323cb6c13cb8fd403c8b122c4a0fb8
3
+ metadata.gz: 56664d3e955f53dbeec1d586c31b57c860a208cedff76990236a728de1c6f9ec
4
+ data.tar.gz: 6a427597a32d8a2d848d2f51ed9cf45d6a3ea41dd9ea77c2b215b4a66846170e
5
5
  SHA512:
6
- metadata.gz: da311c75a34438a39c2bc2e2dc48916e205a15fee4aa40aeb609136ad2a26cba40b3c2ca919f5812e48aedde54bd22ac72ff778e86162e1f4ef29920468470b2
7
- data.tar.gz: 9ad51a591b17b48c79afcb571090aa402338a87f491c0aba2f27b89ad0d28fd16f1e5a89b9f54f61790cc1c5ac15bec35e97698b3f7afefd157144cf513101c3
6
+ metadata.gz: b34bc02259d6a33a0d1fa1dac2f2e71473cc543515df2088766b736eed4cdc8e9925616cca6397a5318d22bdcafbf910e8bea6ea9f93e1de4746613f3aa747b2
7
+ data.tar.gz: 52e3f5a2dda49da303b118a4962ca479bb43b628d363ff133c23939fc523235a01065441cea4002dccd33130ace25149da275ddb9839dc864b2e442377f88a28
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.12.1 (2026-06-06)
4
+
5
+ ### Added
6
+ - **Models::Sanitizable**: opt-in HTML sanitization for string attributes — defense-in-depth on top of Rails' default output escaping (not a replacement for it). `sanitizable :body, with: :safe_list` is **non-destructive by default** (`on: :read`): it adds a `sanitized_<field>` reader and leaves the stored column raw. `on: :write` is an explicit, lossy opt-in that overwrites the column in `before_validation` (so presence/length validations see the cleaned value). Presets: `:strip` (remove all tags), `:safe_list` (Rails' allow-list), `:no_links`, `:none`, plus custom `Array` / `Hash` (`{ tags:, attributes: }`) allow-lists and a `Proc` escape hatch. Schema-checked via `ColumnGuard`. Zero new runtime dependencies.
7
+ - **Controllers::SecureHeadable**: modern security response headers + a thin delegation to Rails' native Content-Security-Policy DSL. `secure_headers :nosniff, :sameorigin_frame, :no_referrer_leak, :no_cross_domain, :disable_legacy_xss` (and custom `"Header-Name" => "value"` pairs), applied in an `after_action`. `content_security_policy_for(report_only:, only:, except:, …, &block)` forwards straight to Rails. Ships `X-XSS-Protection: 0` (the only correct modern value) and deliberately does **not** scrub params. Zero new runtime dependencies.
8
+ - **Support::HtmlSanitizers**: shared, lazily-memoized, feature-detected (HTML5/HTML4) `FullSanitizer` / `SafeListSanitizer` / `LinkSanitizer` instances backing `Models::Sanitizable`, reusing the `rails-html-sanitizer` that already ships with Action View.
9
+
10
+ ### Notes
11
+ - All changes are additive and backward-compatible. `ConcernsOnRails::Sanitizable` is aliased to `Models::Sanitizable`; the controller concern stays namespace-only (`ConcernsOnRails::Controllers::SecureHeadable`), matching the existing controller concerns.
12
+ - These features were first tagged as `1.12.0`, but that tag's CI lint failed before the RubyGems publish step, so it was never released. `1.12.1` is the first published version carrying them.
13
+
14
+ ## 1.11.2 (2026-06-06)
15
+
16
+ ### Added
17
+ - **Models::Taggable**: Lightweight, dependency-free tagging over a single string column (no join tables, no tagging engine; works on any database including SQLite). `taggable_by :tags` adds `tag_list` get/set (accepts a String or an Array, stripped + de-duped), `add_tags` / `remove_tags`, a `tagged_with?` predicate, a boundary-safe `tagged_with(*tags, any:)` class scope (AND by default, OR with `any: true`), and `all_tags`. Options: `delimiter:` and `downcase:`. Reach for `acts-as-taggable-on` when you need tag contexts, ownership, or tag clouds.
18
+ - **Models::SoftDeletable**: `soft_deletable_by` gained `default_scope:` (default `true`) to opt out of the deleted-hiding `default_scope`; new explicit `soft_delete_all` class method (preferred over the `destroy_all` override).
19
+ - **Models::Sluggable**: `sluggable_by` gained `reserved_words:` (reject slugs like `new` / `edit` / `admin` — saving such a record fails validation) and `finders: true` (`Model.find` accepts a slug directly), layering friendly_id's `:reserved` / `:finders` modules.
20
+ - **Models::Sortable**: `sortable_by` now threads acts_as_list's `scope:` (independent position sequence per group) and `add_new_at:` (`:top` / `:bottom`) options through to `acts_as_list`.
21
+
22
+ ### Fixed
23
+ - **Models::SoftDeletable**: `soft_delete!` / `restore!` (and the bulk `soft_delete_all` / `restore_all` / `destroy_all`) now run inside a transaction, so a raising `before_*` / `after_*` hook rolls the timestamp change back instead of leaving a half-applied state — adopting `discard`'s transactional playbook.
24
+
25
+ ### Notes
26
+ - All changes are backward-compatible: the soft-delete `default_scope` stays on by default and `destroy_all` continues to soft-delete. New models are encouraged to set `default_scope: false` and use the explicit `soft_delete_all`.
27
+
3
28
  ## 1.11.1 (2026-06-05)
4
29
 
5
30
  ### Added
data/README.md CHANGED
@@ -39,6 +39,8 @@ Article.published.without_deleted.find("hello-world")
39
39
  - [Sequenceable](#-sequenceable) — ordered, human-friendly reference numbers
40
40
  - [Stateable](#-stateable) — lightweight string-backed state machine
41
41
  - [Addressable](#-addressable) — postal address normalization + format validation
42
+ - [Taggable](#-taggable) — lightweight tagging over a single column
43
+ - [Sanitizable](#-sanitizable) — opt-in HTML sanitization (XSS defense-in-depth)
42
44
  - **Controller concerns**
43
45
  - [Paginatable](#-paginatable) — offset pagination with headers
44
46
  - [Filterable](#-filterable) — declarative URL-param filters
@@ -46,6 +48,7 @@ Article.published.without_deleted.find("hello-world")
46
48
  - [Respondable](#-respondable) — standardized JSON envelopes
47
49
  - [ErrorHandleable](#-errorhandleable) — JSON `rescue_from` handlers for common controller errors
48
50
  - [Includable](#-includable) — whitelisted association sideloading + sparse fieldsets
51
+ - [SecureHeadable](#-secureheadable) — security response headers + native CSP DSL
49
52
  - [Module paths & namespacing](#-module-paths--namespacing)
50
53
  - [Development](#-development)
51
54
  - [Contributing](#-contributing)
@@ -55,7 +58,7 @@ Article.published.without_deleted.find("hello-world")
55
58
 
56
59
  ## ✨ Why this gem?
57
60
 
58
- - **Fourteen model concerns + six controller concerns**, all production-ready
61
+ - **Sixteen model concerns + seven controller concerns**, all production-ready
59
62
  - **One include, one macro** — no boilerplate, no glue code
60
63
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
61
64
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
@@ -158,6 +161,13 @@ Post.friendly.find("old-slug") # still resolves to the renamed post
158
161
 
159
162
  # Unique slug only within a scope column (same slug allowed in different accounts)
160
163
  sluggable_by :title, scope: :account_id
164
+
165
+ # Reject reserved slugs — saving a record whose slug would be reserved fails validation
166
+ sluggable_by :title, reserved_words: %w[new edit admin]
167
+
168
+ # Let Model.find accept a slug directly (not just the id)
169
+ sluggable_by :title, finders: true
170
+ Post.find("hello-world") # resolves by slug
161
171
  ```
162
172
 
163
173
  **Notes**
@@ -191,6 +201,8 @@ Task.last.move_higher
191
201
  sortable_by :priority # ascending priority
192
202
  sortable_by priority: :desc # descending priority
193
203
  sortable_by :position, use_acts_as_list: false # just the default_scope ordering, no acts_as_list
204
+ sortable_by :position, scope: :list_id # independent position sequence per list (acts_as_list scope:)
205
+ sortable_by :position, add_new_at: :top # new rows insert at the top (acts_as_list add_new_at:)
194
206
  ```
195
207
 
196
208
  **Notes**
@@ -246,7 +258,7 @@ publishable_by :published_at, default_scope: true
246
258
 
247
259
  ## ❌ SoftDeletable
248
260
 
249
- Soft delete records using a timestamp field (default: `deleted_at`). Includes a `default_scope` that hides deleted records and overrides `destroy_all` to soft-delete in bulk.
261
+ Soft delete records using a timestamp field (default: `deleted_at`). By default a `default_scope` hides deleted records — **opt out** with `soft_deletable_by :deleted_at, default_scope: false` and chain `.without_deleted` explicitly (the safer choice for new models, avoiding `default_scope`'s join/uniqueness footguns). `soft_delete!` / `restore!` and the bulk helpers run inside a transaction, so a raising hook rolls the change back.
250
262
 
251
263
  ```ruby
252
264
  class User < ApplicationRecord
@@ -280,11 +292,14 @@ User.unscoped # everything (deleted + non-deleted)
280
292
  **Bulk operations**
281
293
 
282
294
  ```ruby
283
- User.destroy_all # soft-deletes all matching records
295
+ User.soft_delete_all # soft-deletes all matching records (explicit — preferred)
296
+ User.destroy_all # alias of soft_delete_all (kept for backwards compatibility)
284
297
  User.really_destroy_all # hard-deletes all matching records
285
298
  User.restore_all # restores all soft-deleted records
286
299
  ```
287
300
 
301
+ All of these run in a transaction, so a raising hook rolls the whole batch back.
302
+
288
303
  **Lifecycle hooks** — override these methods on the model:
289
304
 
290
305
  ```ruby
@@ -775,6 +790,87 @@ end
775
790
 
776
791
  ---
777
792
 
793
+ ## 🏷️ Taggable
794
+
795
+ Lightweight, dependency-free tagging stored in a **single string column** — no join tables, no tagging engine. Works on any database, including SQLite.
796
+
797
+ ```ruby
798
+ class Article < ApplicationRecord
799
+ include ConcernsOnRails::Taggable
800
+
801
+ taggable_by :tags # default column :tags
802
+ # taggable_by :skills, downcase: true # custom column, case-folded
803
+ end
804
+
805
+ article = Article.new
806
+ article.tag_list = "Ruby, Rails, Ruby" # accepts a String or an Array
807
+ article.tag_list # => ["Ruby", "Rails"] (stripped + de-duped)
808
+ article.add_tags("api")
809
+ article.remove_tags("Rails")
810
+ article.tagged_with?("ruby") # => membership predicate
811
+ article.save!
812
+
813
+ Article.tagged_with("ruby", "rails") # records carrying BOTH tags
814
+ Article.tagged_with("ruby", "go", any: true) # records carrying ANY tag
815
+ Article.all_tags # => sorted unique tags in use
816
+ ```
817
+
818
+ **Options**
819
+
820
+ | Option | Default | Purpose |
821
+ |--------------|---------|------------------------------------------------------------------|
822
+ | `delimiter:` | `","` | Character joining the stored tags (a tag must not contain it). |
823
+ | `downcase:` | `false` | Case-fold tags on write so matching is case-insensitive. |
824
+
825
+ **Notes**
826
+ - Matching is **boundary-safe** — searching `rail` does not match `rails`. An explicit SQL `ESCAPE` clause makes tags containing `_` / `%` match literally on every adapter.
827
+ - Tags are normalized in `before_validation`, so a direct `record.tags = "a, b"` assignment is cleaned too. An empty list stores `NULL`.
828
+ - Reach for [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) when you need tag contexts, ownership, counts/clouds, or polymorphic tags shared across models.
829
+
830
+ ---
831
+
832
+ ## 🧼 Sanitizable
833
+
834
+ Opt-in HTML sanitization for string attributes — **defense-in-depth, not a replacement for Rails' default output escaping** (`<%= %>` already escapes). Reach for it on the rare column you render as trusted HTML (`raw` / `html_safe`) or that must stay plain text. Zero extra dependencies — it uses the `rails-html-sanitizer` that already ships with Action View.
835
+
836
+ ```ruby
837
+ class Article < ApplicationRecord
838
+ include ConcernsOnRails::Sanitizable
839
+
840
+ # DEFAULT (on: :read) — non-destructive. The column stays raw; a
841
+ # `sanitized_<field>` reader returns the cleaned value:
842
+ sanitizable :body, with: :safe_list # => article.sanitized_body
843
+ sanitizable :summary, with: :strip # => article.sanitized_summary
844
+ sanitizable :body, with: { tags: %w[b i a], attributes: %w[href] }
845
+
846
+ # EXPLICIT destructive opt-in — for plain-text-only columns only:
847
+ sanitizable :title, with: :strip, on: :write # overwrites in before_validation
848
+ end
849
+
850
+ article = Article.new(body: "<b>Hi</b><script>alert(1)</script>")
851
+ article.body # => "<b>Hi</b><script>alert(1)</script>" (raw, intact)
852
+ article.sanitized_body # => "<b>Hi</b>alert(1)" (script tag dropped)
853
+ ```
854
+
855
+ **Presets** (`with:`)
856
+
857
+ | Preset | Behavior |
858
+ |--------------|------------------------------------------------------------------------|
859
+ | `:strip` | Remove all tags, keep inner text (the default). |
860
+ | `:safe_list` | Rails' allow-list: keep formatting tags, drop `<script>` / `<iframe>`. |
861
+ | `:no_links` | Strip only `<a>` tags, keep their text. |
862
+ | `:none` | No-op (declare the field / reader without transforming). |
863
+ | `Array` | Custom tag allow-list, e.g. `with: %w[b i a]`. |
864
+ | `Hash` | `{ tags: [...], attributes: [...] }` allow-list. |
865
+ | `Proc` | Used as-is (you own the non-String guard). |
866
+
867
+ **Notes**
868
+ - `on: :read` (default) is **non-destructive**: it adds a `sanitized_<field>` reader and leaves the stored column untouched.
869
+ - `on: :write` overwrites the column in `before_validation` — **lossy and irreversible** (never use it on code, Markdown, math, or prices), and bypassed by `update_column` / `update_all` / raw SQL.
870
+ - For full user-authored rich text, prefer [Action Text](https://guides.rubyonrails.org/action_text_overview.html).
871
+
872
+ ---
873
+
778
874
  # 🎮 Controller Concerns
779
875
 
780
876
  Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
@@ -998,6 +1094,46 @@ GET /articles?include=author,comments&fields[articles]=id,title&fields[authors]=
998
1094
 
999
1095
  ---
1000
1096
 
1097
+ ## 🛡️ SecureHeadable
1098
+
1099
+ Modern security response headers + a thin wrapper over Rails' native Content-Security-Policy DSL. Defense-in-depth on top of output escaping — it does **not** scrub request params and never re-enables the deprecated X-XSS-Protection auditor. Zero extra dependencies.
1100
+
1101
+ ```ruby
1102
+ class ApplicationController < ActionController::Base
1103
+ include ConcernsOnRails::Controllers::SecureHeadable
1104
+
1105
+ # Preset headers, plus any custom "Header-Name" => "value" pairs:
1106
+ secure_headers :nosniff, :sameorigin_frame, :no_referrer_leak, :disable_legacy_xss
1107
+ secure_headers "Permissions-Policy" => "geolocation=()"
1108
+
1109
+ # Delegates to Rails' native CSP DSL — roll out report-only FIRST:
1110
+ content_security_policy_for(report_only: true) do |policy|
1111
+ policy.default_src :self
1112
+ policy.script_src :self
1113
+ policy.object_src :none
1114
+ end
1115
+ end
1116
+ ```
1117
+
1118
+ **Header presets** (`secure_headers`)
1119
+
1120
+ | Preset | Header |
1121
+ |-----------------------|--------------------------------------------------------|
1122
+ | `:nosniff` | `X-Content-Type-Options: nosniff` |
1123
+ | `:sameorigin_frame` | `X-Frame-Options: SAMEORIGIN` |
1124
+ | `:deny_frame` | `X-Frame-Options: DENY` |
1125
+ | `:no_referrer_leak` | `Referrer-Policy: strict-origin-when-cross-origin` |
1126
+ | `:no_cross_domain` | `X-Permitted-Cross-Domain-Policies: none` |
1127
+ | `:disable_legacy_xss` | `X-XSS-Protection: 0` (the only correct modern value) |
1128
+
1129
+ **Notes**
1130
+ - Headers are applied in an `after_action`, so they reinforce Rails' middleware defaults; later `secure_headers` declarations win on a colliding name.
1131
+ - `content_security_policy_for` forwards `report_only:` and per-action `only:` / `except:` / `if:` / `unless:` straight to Rails — it never re-implements CSP. Per-controller CSP overrides the global initializer for that controller.
1132
+ - CSP nonce generation (`content_security_policy_nonce_generator`) is app-wide initializer config and intentionally stays out of the concern.
1133
+ - These headers mitigate clickjacking / MIME-sniffing and (via CSP) XSS as **defense-in-depth** — output escaping remains the primary defense.
1134
+
1135
+ ---
1136
+
1001
1137
  ## 🗂️ Module paths & namespacing
1002
1138
 
1003
1139
  Every concern is available under two paths:
@@ -1023,13 +1159,29 @@ Both forms reference the same module, so you can freely mix them.
1023
1159
 
1024
1160
  ---
1025
1161
 
1162
+ ## 🧭 Philosophy & when to reach for a dedicated gem
1163
+
1164
+ `concerns_on_rails` aims to cover the common 80% of each behavior with **one `include` + one declarative macro**, **schema-validated** configuration, **no-surprise defaults**, and **lean dependencies** (only `acts_as_list` and `friendly_id`; controller concerns have none). It is deliberately *not* a re-implementation of the category leaders — reach for a dedicated gem when you outgrow the basics:
1165
+
1166
+ | Need | Use instead |
1167
+ |------|-------------|
1168
+ | Complex state machines (callbacks, transition logging) | [`aasm`](https://github.com/aasm/aasm) |
1169
+ | Association-cascade soft delete / sentinel-aware unique indexes | [`paranoia`](https://github.com/rubysherpas/paranoia) or [`discard`](https://github.com/jhawthorn/discard) |
1170
+ | Tagging with contexts, ownership, or tag clouds | [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) |
1171
+ | Full-text search with ranking / stemming | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
1172
+ | Audit trails / version history | [`paper_trail`](https://github.com/paper-trail-gem/paper_trail) / [`audited`](https://github.com/collectiveidea/audited) |
1173
+
1174
+ `Sluggable` wraps [`friendly_id`](https://github.com/norman/friendly_id) and `Sortable` wraps [`acts_as_list`](https://github.com/brendon/acts_as_list), so you get those leaders' engines behind the declarative macro.
1175
+
1176
+ ---
1177
+
1026
1178
  ## 🛠️ Development
1027
1179
 
1028
1180
  ```sh
1029
1181
  bundle install # install dev dependencies
1030
1182
  bundle exec rspec # run the test suite
1031
1183
  gem build concerns_on_rails.gemspec # build the gem
1032
- gem install ./concerns_on_rails-1.11.0.gem # install locally
1184
+ gem install ./concerns_on_rails-1.11.2.gem # install locally
1033
1185
  ```
1034
1186
 
1035
1187
  The test suite uses an in-memory SQLite database and a lightweight `FakeController` harness for controller-concern specs — no Rails routes or boot required.
@@ -0,0 +1,105 @@
1
+ require "active_support/concern"
2
+
3
+ module ConcernsOnRails
4
+ module Controllers
5
+ # Adds modern security response headers and wires Rails' native
6
+ # Content-Security-Policy DSL. Defense-in-depth on top of output escaping —
7
+ # this does NOT scrub request params (context-blind and lossy) and never
8
+ # re-enables the deprecated X-XSS-Protection auditor.
9
+ #
10
+ # class ApplicationController < ActionController::Base
11
+ # include ConcernsOnRails::Controllers::SecureHeadable
12
+ #
13
+ # # Apply preset headers, plus any custom "Header-Name" => "value" pairs:
14
+ # secure_headers :nosniff, :sameorigin_frame, :no_referrer_leak, :disable_legacy_xss
15
+ # secure_headers "Permissions-Policy" => "geolocation=()"
16
+ #
17
+ # # Delegates to Rails' native CSP DSL — roll out report-only FIRST:
18
+ # content_security_policy_for(report_only: true) do |policy|
19
+ # policy.default_src :self
20
+ # policy.script_src :self
21
+ # policy.object_src :none
22
+ # end
23
+ # end
24
+ #
25
+ # The headers mitigate clickjacking / MIME-sniffing and (via CSP) XSS as
26
+ # defense-in-depth — they are NOT a standalone XSS fix; output escaping
27
+ # remains the primary defense. Per-controller CSP overrides the global
28
+ # initializer for that controller. CSP nonce generation
29
+ # (content_security_policy_nonce_generator / _nonce_directives) is app-wide
30
+ # initializer configuration and intentionally stays out of this concern.
31
+ #
32
+ # Header presets (the `secure_headers` arguments):
33
+ # :nosniff — X-Content-Type-Options: nosniff
34
+ # :sameorigin_frame — X-Frame-Options: SAMEORIGIN
35
+ # :deny_frame — X-Frame-Options: DENY
36
+ # :no_referrer_leak — Referrer-Policy: strict-origin-when-cross-origin
37
+ # :no_cross_domain — X-Permitted-Cross-Domain-Policies: none
38
+ # :disable_legacy_xss — X-XSS-Protection: 0 (the only correct modern value)
39
+ module SecureHeadable
40
+ extend ActiveSupport::Concern
41
+
42
+ # Frozen, string-only header presets, each "Header-Name" => "value".
43
+ # :disable_legacy_xss emits "0" deliberately — the legacy browser XSS
44
+ # auditor was itself exploitable and is gone from modern browsers
45
+ # (Rails 7+ ships "0"), so "0" is the only correct value.
46
+ PRESETS = {
47
+ nosniff: %w[X-Content-Type-Options nosniff],
48
+ sameorigin_frame: %w[X-Frame-Options SAMEORIGIN],
49
+ deny_frame: %w[X-Frame-Options DENY],
50
+ no_referrer_leak: %w[Referrer-Policy strict-origin-when-cross-origin],
51
+ no_cross_domain: %w[X-Permitted-Cross-Domain-Policies none],
52
+ disable_legacy_xss: %w[X-XSS-Protection 0]
53
+ }.freeze
54
+
55
+ included do
56
+ class_attribute :secure_headable_headers, instance_accessor: false, default: {}
57
+ # after_action (not before) so the headers survive render and reinforce
58
+ # Rails' middleware defaults when a name collides.
59
+ after_action :apply_secure_headers
60
+ end
61
+
62
+ class_methods do
63
+ # Register preset headers (by symbol) plus optional custom
64
+ # "Header-Name" => "value" pairs. Later declarations win on collision.
65
+ def secure_headers(*presets, **custom)
66
+ resolved = presets.to_h do |key|
67
+ PRESETS.fetch(key) do
68
+ raise ArgumentError,
69
+ "ConcernsOnRails::Controllers::SecureHeadable: unknown preset '#{key}'. " \
70
+ "Valid presets: #{PRESETS.keys.join(', ')}"
71
+ end
72
+ end
73
+
74
+ self.secure_headable_headers =
75
+ secure_headable_headers.merge(resolved).merge(custom.transform_keys(&:to_s))
76
+ end
77
+
78
+ # Thin pass-through to Rails' native CSP DSL — never re-implement CSP.
79
+ # Forwards per-action conditions (only: / except: / if: / unless:) and
80
+ # the policy block straight through.
81
+ def content_security_policy_for(report_only: false, **action_opts, &block)
82
+ unless respond_to?(:content_security_policy)
83
+ raise ArgumentError,
84
+ "ConcernsOnRails::Controllers::SecureHeadable: CSP requires " \
85
+ "ActionController::ContentSecurityPolicy (Rails 5.2+)"
86
+ end
87
+
88
+ if report_only
89
+ content_security_policy_report_only(true, **action_opts, &block)
90
+ else
91
+ content_security_policy(**action_opts, &block)
92
+ end
93
+ end
94
+ end
95
+
96
+ # Public so subclasses can override; guarded exactly like Paginatable so
97
+ # it no-ops cleanly when there is no response object.
98
+ def apply_secure_headers
99
+ return unless respond_to?(:response) && response
100
+
101
+ self.class.secure_headable_headers.each { |name, value| response.set_header(name, value) }
102
+ end
103
+ end
104
+ end
105
+ end
@@ -16,4 +16,6 @@ module ConcernsOnRails
16
16
  Stateable = Models::Stateable
17
17
  Addressable = Models::Addressable
18
18
  Sequenceable = Models::Sequenceable
19
+ Taggable = Models::Taggable
20
+ Sanitizable = Models::Sanitizable
19
21
  end
@@ -0,0 +1,148 @@
1
+ require "active_support/concern"
2
+ require "concerns_on_rails/support/html_sanitizers"
3
+
4
+ module ConcernsOnRails
5
+ module Models
6
+ # Opt-in HTML sanitization for string attributes — defense-in-depth, NOT a
7
+ # substitute for Rails' default output escaping.
8
+ #
9
+ # ESCAPE-FIRST: Rails already HTML-escapes `<%= record.body %>` via
10
+ # SafeBuffer, so for ordinary columns you need nothing here. Reach for this
11
+ # concern only for the rare column you render as trusted HTML
12
+ # (`raw` / `html_safe`) or that must be kept plain text. For full
13
+ # user-authored rich text, prefer Action Text.
14
+ #
15
+ # class Article < ApplicationRecord
16
+ # include ConcernsOnRails::Models::Sanitizable
17
+ #
18
+ # # DEFAULT (on: :read) — non-destructive. The stored column stays raw;
19
+ # # a `sanitized_<field>` reader returns the cleaned value:
20
+ # sanitizable :body, with: :safe_list # => article.sanitized_body
21
+ # sanitizable :summary, with: :strip # => article.sanitized_summary
22
+ # sanitizable :body, with: { tags: %w[b i a], attributes: %w[href] }
23
+ #
24
+ # # EXPLICIT destructive opt-in — for plain-text-only columns only:
25
+ # sanitizable :title, with: :strip, on: :write # overwrites in before_validation
26
+ # end
27
+ #
28
+ # WARNING: `on: :write` is lossy and irreversible — never use it on code,
29
+ # Markdown, math, or anything where `<` / `>` are legitimate. It is also
30
+ # bypassed by `update_column` / `update_all` / raw SQL, which skip
31
+ # callbacks. The non-destructive `on: :read` default is preferred.
32
+ #
33
+ # Presets (the `with:` argument):
34
+ # :strip — remove all tags, keep inner text (the default)
35
+ # :safe_list — Rails' allow-list: keep formatting tags, drop <script> etc.
36
+ # :no_links — strip only <a> tags, keep their text
37
+ # :none — no-op (declare the field / reader without transforming)
38
+ # Array — custom tag allow-list, e.g. with: %w[b i a]
39
+ # Hash — { tags: [...], attributes: [...] } allow-list
40
+ # Proc — used as-is (the caller owns the non-String guard)
41
+ module Sanitizable
42
+ extend ActiveSupport::Concern
43
+
44
+ # Frozen, string-safe lambdas — non-String values pass through untouched,
45
+ # exactly like Normalizable::PRESETS. Each resolves its sanitizer through
46
+ # the shared Support helper (fully qualified, so there is no lexical-scope
47
+ # dependency and libgumbo is probed once, not per access) and always
48
+ # returns a plain String via #to_s, so a SafeBuffer is never persisted.
49
+ PRESETS = {
50
+ strip: ->(v) { v.is_a?(String) ? ConcernsOnRails::Support::HtmlSanitizers.full.sanitize(v).to_s : v },
51
+ safe_list: ->(v) { v.is_a?(String) ? ConcernsOnRails::Support::HtmlSanitizers.safe.sanitize(v).to_s : v },
52
+ no_links: ->(v) { v.is_a?(String) ? ConcernsOnRails::Support::HtmlSanitizers.link.sanitize(v).to_s : v },
53
+ none: ->(v) { v }
54
+ }.freeze
55
+
56
+ included do
57
+ # field => { sanitizer: <lambda>, on: :read|:write }
58
+ class_attribute :sanitizable_rules, instance_accessor: false, default: {}
59
+ before_validation :apply_sanitizations
60
+ end
61
+
62
+ class_methods do
63
+ include ConcernsOnRails::Support::ColumnGuard
64
+
65
+ # Declare which string fields to sanitize, how, and when.
66
+ # sanitizable :body, with: :safe_list # non-destructive reader
67
+ # sanitizable :title, with: :strip, on: :write # destructive overwrite
68
+ def sanitizable(*fields, with: :strip, on: :read)
69
+ raise ArgumentError, "ConcernsOnRails::Models::Sanitizable: at least one field is required" if fields.empty?
70
+
71
+ unless %i[read write].include?(on)
72
+ raise ArgumentError,
73
+ "ConcernsOnRails::Models::Sanitizable: :on must be :read or :write, got #{on.inspect}"
74
+ end
75
+
76
+ sanitizer = resolve_sanitizer(with)
77
+ ensure_columns!("ConcernsOnRails::Models::Sanitizable", fields)
78
+
79
+ fields.each do |field|
80
+ key = field.to_sym
81
+ self.sanitizable_rules = sanitizable_rules.merge(key => { sanitizer: sanitizer, on: on })
82
+
83
+ # Non-destructive default: a clean reader, with the raw column intact.
84
+ define_method("sanitized_#{field}") { sanitizer.call(self[key]) } if on == :read
85
+ end
86
+ end
87
+ end
88
+
89
+ class_methods do # rubocop:disable Metrics/BlockLength
90
+ private
91
+
92
+ # Accepts a preset Symbol, a Proc (used as-is), an Array (custom tags
93
+ # allow-list), or a Hash with :tags / :attributes.
94
+ def resolve_sanitizer(with)
95
+ case with
96
+ when Symbol then preset_sanitizer(with)
97
+ when Proc then with
98
+ when Array then allowlist_sanitizer(tags: with.map(&:to_s))
99
+ when Hash then hash_allowlist_sanitizer(with)
100
+ else
101
+ raise ArgumentError,
102
+ "ConcernsOnRails::Models::Sanitizable: :with must be a preset symbol, an allow-list " \
103
+ "(Array or Hash), or a Proc/lambda, got #{with.class}"
104
+ end
105
+ end
106
+
107
+ def preset_sanitizer(name)
108
+ PRESETS.fetch(name) do
109
+ raise ArgumentError,
110
+ "ConcernsOnRails::Models::Sanitizable: unknown preset '#{name}'. " \
111
+ "Valid presets: #{PRESETS.keys.join(', ')}"
112
+ end
113
+ end
114
+
115
+ def hash_allowlist_sanitizer(opts)
116
+ unknown = opts.keys - %i[tags attributes]
117
+ unless unknown.empty?
118
+ raise ArgumentError,
119
+ "ConcernsOnRails::Models::Sanitizable: allow-list keys must be :tags / :attributes, got #{unknown.inspect}"
120
+ end
121
+
122
+ allowlist_sanitizer(tags: opts[:tags]&.map(&:to_s), attributes: opts[:attributes]&.map(&:to_s))
123
+ end
124
+
125
+ # Builds a SafeListSanitizer lambda restricted to the given tags/attributes.
126
+ def allowlist_sanitizer(tags: nil, attributes: nil)
127
+ options = {}
128
+ options[:tags] = tags if tags
129
+ options[:attributes] = attributes if attributes
130
+ ->(v) { v.is_a?(String) ? ConcernsOnRails::Support::HtmlSanitizers.safe.sanitize(v, **options).to_s : v }
131
+ end
132
+ end
133
+
134
+ # Only fields declared with on: :write are mutated; on: :read fields keep
135
+ # their raw column value and are exposed through their sanitized_ reader.
136
+ def apply_sanitizations
137
+ self.class.sanitizable_rules.each do |field, rule|
138
+ next unless rule[:on] == :write
139
+
140
+ value = self[field]
141
+ next if value.nil?
142
+
143
+ self[field] = rule[:sanitizer].call(value) # plain String, never a SafeBuffer
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -34,27 +34,40 @@ module ConcernsOnRails
34
34
  # Define sluggable field, with optional friendly_id features.
35
35
  # Example:
36
36
  # sluggable_by :wonderful_name
37
- # sluggable_by :title, history: true # old slugs keep resolving (needs a friendly_id_slugs table)
38
- # sluggable_by :title, scope: :account_id # slugs unique per scope column
39
- def sluggable_by(field, history: false, scope: nil)
37
+ # sluggable_by :title, history: true # old slugs keep resolving (needs a friendly_id_slugs table)
38
+ # sluggable_by :title, scope: :account_id # slugs unique per scope column
39
+ # sluggable_by :title, reserved_words: %w[new] # block these slugs (a UUID is appended instead)
40
+ # sluggable_by :title, finders: true # Model.find accepts a slug directly
41
+ def sluggable_by(field, history: false, scope: nil, reserved_words: nil, finders: false)
40
42
  self.sluggable_field = field.to_sym
41
43
  ensure_columns!("ConcernsOnRails::Models::Sluggable", [sluggable_field, scope].compact)
42
- reconfigure_friendly_id(history: history, scope: scope) if history || scope
44
+ return unless history || scope || reserved_words || finders
45
+
46
+ reconfigure_friendly_id(history: history, scope: scope,
47
+ reserved_words: reserved_words, finders: finders)
43
48
  end
44
49
 
45
50
  private
46
51
 
47
52
  # Re-runs friendly_id with the extra modules. friendly_id merges config
48
- # across calls, so this layers :history / :scoped onto the base :slugged.
49
- def reconfigure_friendly_id(history:, scope:)
53
+ # across calls, so this layers :history / :scoped / :finders / :reserved onto :slugged.
54
+ def reconfigure_friendly_id(history:, scope:, reserved_words: nil, finders: false)
50
55
  modules = [:slugged]
51
56
  modules << :history if history
52
57
  modules << :scoped if scope
53
- options = { use: modules }
54
- options[:scope] = scope if scope
58
+ modules << :finders if finders
59
+ modules << :reserved if reserved_words
55
60
  # friendly_id's second argument is a positional options hash (not kwargs),
56
61
  # so pass it positionally to stay correct on both Ruby 2.7 and 3.x.
57
- friendly_id(:slug_source, options)
62
+ friendly_id(:slug_source, friendly_id_options(modules, scope, reserved_words))
63
+ end
64
+
65
+ # Build friendly_id's positional options hash from the resolved modules.
66
+ def friendly_id_options(modules, scope, reserved_words)
67
+ options = { use: modules }
68
+ options[:scope] = scope if scope
69
+ options[:reserved_words] = Array(reserved_words).map(&:to_s) if reserved_words
70
+ options
58
71
  end
59
72
  end
60
73
 
@@ -9,6 +9,11 @@ module ConcernsOnRails
9
9
  # declare class attributes and set default values
10
10
  class_attribute :soft_delete_field, instance_accessor: false, default: :deleted_at
11
11
  class_attribute :soft_delete_touch, instance_accessor: false, default: true
12
+ # Whether `.all` hides soft-deleted rows via a default_scope. ON by default for
13
+ # backwards compatibility; opt out with `soft_deletable_by ..., default_scope: false`.
14
+ # A default_scope is sticky and breaks unscoped joins / uniqueness validations /
15
+ # eager-loading, so new models are encouraged to disable it and chain `.without_deleted`.
16
+ class_attribute :soft_delete_default_scope, instance_accessor: false, default: true
12
17
 
13
18
  # scopes
14
19
  scope :active, -> { unscope(where: soft_delete_field).where(soft_delete_field => nil) }
@@ -19,25 +24,35 @@ module ConcernsOnRails
19
24
  scope :with_deleted, -> { unscope(where: soft_delete_field) }
20
25
  # Records soft-deleted within the last `duration` (e.g. `deleted_within(7.days)`).
21
26
  scope :deleted_within, ->(duration) { soft_deleted.where(soft_delete_field => duration.ago..) }
22
- # Optionally, uncomment to hide deleted by default:
23
- default_scope { without_deleted }
27
+
28
+ # Hide soft-deleted rows from `.all` only when enabled (the default). The block is
29
+ # evaluated lazily, so toggling `soft_delete_default_scope` via the macro takes effect.
30
+ default_scope { soft_delete_default_scope ? without_deleted : all }
24
31
  end
25
32
 
26
33
  class_methods do
27
34
  include ConcernsOnRails::Support::ColumnGuard
28
35
 
29
- # Define soft delete field and options
36
+ # Define soft delete field and options.
30
37
  # Example:
31
38
  # soft_deletable_by :deleted_at, touch: false
32
- def soft_deletable_by(field = nil, touch: true)
39
+ # soft_deletable_by :deleted_at, default_scope: false # don't hide deleted rows from .all
40
+ def soft_deletable_by(field = nil, touch: true, default_scope: true)
33
41
  self.soft_delete_field = field || :deleted_at
34
42
  self.soft_delete_touch = touch
43
+ self.soft_delete_default_scope = default_scope
35
44
  ensure_columns!("ConcernsOnRails::Models::SoftDeletable", soft_delete_field)
36
45
  end
37
46
 
38
- # Override destroy_all to perform soft delete on all records
47
+ # Soft-delete every matching record, wrapped in a transaction so the batch is atomic.
48
+ def soft_delete_all
49
+ transaction { all.each(&:soft_delete!) }
50
+ end
51
+
52
+ # Override destroy_all to soft delete. Kept for backwards compatibility, but prefer the
53
+ # explicit `soft_delete_all` — silently redefining a standard AR method is a known footgun.
39
54
  def destroy_all
40
- all.each(&:soft_delete!)
55
+ soft_delete_all
41
56
  end
42
57
 
43
58
  # Provide really_destroy_all to hard delete all records
@@ -45,9 +60,9 @@ module ConcernsOnRails
45
60
  unscoped.delete_all
46
61
  end
47
62
 
48
- # Restore every soft-deleted record (mirror of the destroy_all override).
63
+ # Restore every soft-deleted record, atomically (mirror of soft_delete_all).
49
64
  def restore_all
50
- soft_deleted.each(&:restore!)
65
+ transaction { soft_deleted.each(&:restore!) }
51
66
  end
52
67
  end
53
68
 
@@ -60,26 +75,34 @@ module ConcernsOnRails
60
75
  def soft_delete!
61
76
  return true if deleted?
62
77
 
63
- before_soft_delete
64
- result = if self.class.soft_delete_touch
65
- update(self.class.soft_delete_field => Time.zone.now)
66
- else
67
- update_column(self.class.soft_delete_field, Time.zone.now)
68
- end
69
- after_soft_delete if result
78
+ result = false
79
+ # Wrap the timestamp change and its hooks in a transaction so a raising
80
+ # before/after hook rolls the change back instead of leaving a half-applied state.
81
+ transaction do
82
+ before_soft_delete
83
+ result = if self.class.soft_delete_touch
84
+ update(self.class.soft_delete_field => Time.zone.now)
85
+ else
86
+ update_column(self.class.soft_delete_field, Time.zone.now)
87
+ end
88
+ after_soft_delete if result
89
+ end
70
90
  result
71
91
  end
72
92
 
73
93
  def restore!
74
94
  return true unless deleted?
75
95
 
76
- before_restore
77
- result = if self.class.soft_delete_touch
78
- update(self.class.soft_delete_field => nil)
79
- else
80
- update_column(self.class.soft_delete_field, nil)
81
- end
82
- after_restore if result
96
+ result = false
97
+ transaction do
98
+ before_restore
99
+ result = if self.class.soft_delete_touch
100
+ update(self.class.soft_delete_field => nil)
101
+ else
102
+ update_column(self.class.soft_delete_field, nil)
103
+ end
104
+ after_restore if result
105
+ end
83
106
  result
84
107
  end
85
108
 
@@ -34,14 +34,16 @@ module ConcernsOnRails
34
34
  class_methods do
35
35
  include ConcernsOnRails::Support::ColumnGuard
36
36
 
37
- # Define sortable field and direction
37
+ # Define sortable field and direction.
38
38
  # Example:
39
39
  # sortable_by :position
40
40
  # sortable_by position: :asc
41
41
  # sortable_by position: :desc
42
42
  #
43
43
  # sortable_by :position, use_acts_as_list: false
44
- def sortable_by(field_config = nil, use_acts_as_list: true, **field_options)
44
+ # sortable_by :position, scope: :list_id # independent ordering within each list
45
+ # sortable_by :position, add_new_at: :top # new records go to the top of the list
46
+ def sortable_by(field_config = nil, use_acts_as_list: true, scope: nil, add_new_at: nil, **field_options)
45
47
  field_config = field_options if field_config.nil? && field_options.any?
46
48
 
47
49
  # parse field_config
@@ -56,7 +58,14 @@ module ConcernsOnRails
56
58
 
57
59
  ensure_columns!("ConcernsOnRails::Models::Sortable", sortable_field)
58
60
 
59
- acts_as_list column: sortable_field if use_acts_as_list
61
+ return unless use_acts_as_list
62
+
63
+ # Thread acts_as_list's own options through (scope: for per-group ordering,
64
+ # add_new_at: for where freshly-inserted rows land).
65
+ list_options = { column: sortable_field }
66
+ list_options[:scope] = scope unless scope.nil?
67
+ list_options[:add_new_at] = add_new_at unless add_new_at.nil?
68
+ acts_as_list(list_options)
60
69
  end
61
70
 
62
71
  private
@@ -0,0 +1,160 @@
1
+ require "active_support/concern"
2
+
3
+ module ConcernsOnRails
4
+ module Models
5
+ # Lightweight, dependency-free tagging over a single string column.
6
+ # Tags are stored delimiter-joined in one column — no join tables, no
7
+ # tagging engine — so it works on any database, including SQLite.
8
+ #
9
+ # class Article < ApplicationRecord
10
+ # include ConcernsOnRails::Taggable
11
+ #
12
+ # taggable_by :tags # default column :tags
13
+ # # taggable_by :skills, downcase: true # custom column, case-folded
14
+ # end
15
+ #
16
+ # a = Article.new
17
+ # a.tag_list = "Ruby, Rails, Ruby" # accepts a String or an Array
18
+ # a.tag_list # => ["Ruby", "Rails"] (stripped + de-duped)
19
+ # a.add_tags("api"); a.remove_tags("Rails")
20
+ # a.tagged_with?("ruby") # membership predicate
21
+ # a.save!
22
+ #
23
+ # Article.tagged_with("ruby", "rails") # records carrying BOTH tags
24
+ # Article.tagged_with("ruby", "go", any: true) # records carrying ANY tag
25
+ # Article.all_tags # sorted unique tags in use
26
+ #
27
+ # Notes:
28
+ # * Matching is boundary-safe ("rail" does not match "rails").
29
+ # * A tag must not contain the delimiter (default ",").
30
+ # * Reach for acts-as-taggable-on when you need tag contexts, ownership,
31
+ # tag counts/clouds, or polymorphic tags shared across models.
32
+ module Taggable
33
+ extend ActiveSupport::Concern
34
+
35
+ LABEL = "ConcernsOnRails::Models::Taggable".freeze
36
+ DEFAULT_FIELD = :tags
37
+ DEFAULT_DELIMITER = ",".freeze
38
+
39
+ included do
40
+ class_attribute :taggable_field, instance_accessor: false, default: DEFAULT_FIELD
41
+ class_attribute :taggable_delimiter, instance_accessor: false, default: DEFAULT_DELIMITER
42
+ class_attribute :taggable_downcase, instance_accessor: false, default: false
43
+ end
44
+
45
+ # Real module (not `class_methods do`) so the private query helpers live
46
+ # under a single `private`. ActiveSupport::Concern auto-extends ClassMethods.
47
+ module ClassMethods
48
+ include ConcernsOnRails::Support::ColumnGuard
49
+
50
+ # Configure the tag column. See the module docs for the DSL.
51
+ def taggable_by(field = DEFAULT_FIELD, delimiter: DEFAULT_DELIMITER, downcase: false)
52
+ self.taggable_field = field.to_sym
53
+ self.taggable_delimiter = delimiter.to_s
54
+ self.taggable_downcase = downcase
55
+ ensure_columns!(LABEL, taggable_field)
56
+
57
+ before_validation :taggable_normalize!
58
+ end
59
+
60
+ # Records carrying the given tags. `any: true` matches ANY tag (OR);
61
+ # the default requires ALL tags (AND). Returns a chainable relation.
62
+ def tagged_with(*names, any: false)
63
+ tags = taggable_clean_all(names)
64
+ return all if tags.empty?
65
+
66
+ clauses = tags.map { |t| taggable_clause(t) }
67
+ sql = clauses.map(&:first).join(any ? " OR " : " AND ")
68
+ where(sql, *clauses.flat_map(&:last))
69
+ end
70
+
71
+ # All distinct tags currently stored across the table, sorted.
72
+ def all_tags
73
+ pluck(taggable_field).flat_map { |raw| taggable_split(raw) }.uniq.sort
74
+ end
75
+
76
+ # Split a raw stored column value into a normalized tag array.
77
+ def taggable_split(raw)
78
+ taggable_clean_all(raw.to_s.split(taggable_delimiter))
79
+ end
80
+
81
+ # Normalize a single tag (strip + optional downcase).
82
+ def taggable_clean(tag)
83
+ tag = tag.to_s.strip
84
+ taggable_downcase ? tag.downcase : tag
85
+ end
86
+
87
+ private
88
+
89
+ def taggable_clean_all(names)
90
+ names.flatten.map { |t| taggable_clean(t) }.reject(&:blank?).uniq
91
+ end
92
+
93
+ # Boundary-safe match for one tag against the delimiter-joined column.
94
+ # Returns [sql_fragment, [bind_params...]]. An explicit ESCAPE clause makes
95
+ # the backslash escaping below work on every adapter (SQLite has no default
96
+ # LIKE escape), so a tag containing `_` or `%` matches literally.
97
+ def taggable_clause(tag)
98
+ column = "#{connection.quote_table_name(table_name)}.#{connection.quote_column_name(taggable_field)}"
99
+ delim = taggable_delimiter
100
+ escaped = taggable_escape_like(tag)
101
+ esc = " ESCAPE '\\'"
102
+ ["(#{column} = ? OR #{column} LIKE ?#{esc} OR #{column} LIKE ?#{esc} OR #{column} LIKE ?#{esc})",
103
+ [tag, "#{escaped}#{delim}%", "%#{delim}#{escaped}", "%#{delim}#{escaped}#{delim}%"]]
104
+ end
105
+
106
+ # Treat the user's tag as a LIKE literal: %, _ and \ are not wildcards.
107
+ def taggable_escape_like(str)
108
+ str.gsub(/[\\%_]/) { |char| "\\#{char}" }
109
+ end
110
+ end
111
+
112
+ # ---- instance methods ----
113
+
114
+ def tag_list
115
+ self.class.taggable_split(self[self.class.taggable_field])
116
+ end
117
+
118
+ def tag_list=(value)
119
+ tags = taggable_coerce(value)
120
+ self[self.class.taggable_field] = tags.empty? ? nil : tags.join(self.class.taggable_delimiter)
121
+ end
122
+
123
+ def add_tags(*names)
124
+ self.tag_list = tag_list + names.flatten.map { |t| self.class.taggable_clean(t) }
125
+ tag_list
126
+ end
127
+ alias add_tag add_tags
128
+
129
+ def remove_tags(*names)
130
+ drop = names.flatten.map { |t| self.class.taggable_clean(t) }
131
+ self.tag_list = tag_list.reject { |t| drop.include?(t) }
132
+ tag_list
133
+ end
134
+ alias remove_tag remove_tags
135
+
136
+ def tagged_with?(tag)
137
+ tag_list.include?(self.class.taggable_clean(tag))
138
+ end
139
+ alias has_tag? tagged_with?
140
+
141
+ private
142
+
143
+ # before_validation hook — re-normalize whatever sits in the column, covering
144
+ # direct `record.tags = "..."` assignment, not just the tag_list= setter.
145
+ def taggable_normalize!
146
+ field = self.class.taggable_field
147
+ raw = self[field]
148
+ return if raw.nil?
149
+
150
+ tags = self.class.taggable_split(raw)
151
+ self[field] = tags.empty? ? nil : tags.join(self.class.taggable_delimiter)
152
+ end
153
+
154
+ def taggable_coerce(value)
155
+ items = value.is_a?(Array) ? value : value.to_s.split(self.class.taggable_delimiter)
156
+ items.map { |t| self.class.taggable_clean(t) }.reject(&:blank?).uniq
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,63 @@
1
+ require "active_support/concern"
2
+
3
+ begin
4
+ require "rails-html-sanitizer"
5
+ rescue LoadError
6
+ # rails-html-sanitizer ships transitively via actionview in every Rails app
7
+ # (actionview -> rails-html-sanitizer -> loofah), so this require is purely
8
+ # defensive for the rare host that pins an unusually old actionview. If the
9
+ # library is genuinely absent, referencing a sanitizer below raises a clear
10
+ # NameError at first use rather than at gem load.
11
+ end
12
+
13
+ module ConcernsOnRails
14
+ module Support
15
+ # Memoized, feature-detected HTML sanitizer instances shared by the
16
+ # sanitizing concerns (currently Models::Sanitizable).
17
+ #
18
+ # Picks the HTML5 parser (Rails::HTML5::*, the default since Rails 7.1, so
19
+ # it matches the host app's own ActionView sanitize/strip_tags output) when
20
+ # the platform supports it, and otherwise falls back to HTML4 (libgumbo /
21
+ # HTML5 is unavailable on JRuby) — mirroring Rails core.
22
+ #
23
+ # The namespace decision and each sanitizer are built lazily on first use,
24
+ # so libgumbo / ActionView is never probed at file-load time, and the
25
+ # instances are reused (they are thread-safe for #sanitize) rather than
26
+ # re-allocated per attribute access.
27
+ #
28
+ # We reference Rails::HTML5 / Rails::HTML4 explicitly: the bare
29
+ # Rails::HTML::* aliases silently resolve to the HTML4 implementation.
30
+ module HtmlSanitizers
31
+ module_function
32
+
33
+ def namespace
34
+ @namespace ||=
35
+ if defined?(Rails::HTML::Sanitizer) &&
36
+ Rails::HTML::Sanitizer.respond_to?(:html5_support?) &&
37
+ Rails::HTML::Sanitizer.html5_support?
38
+ Rails::HTML5
39
+ else
40
+ Rails::HTML4
41
+ end
42
+ end
43
+
44
+ # Removes every tag, keeping the inner text. The safe default and the
45
+ # only sanitizer appropriate for a destructive write (it cannot
46
+ # reintroduce markup).
47
+ def full
48
+ @full ||= namespace::FullSanitizer.new
49
+ end
50
+
51
+ # Rails' curated allow-list: keeps formatting tags (em / strong / a / p…),
52
+ # drops <script> / <iframe>, and neutralizes javascript: URLs.
53
+ def safe
54
+ @safe ||= namespace::SafeListSanitizer.new
55
+ end
56
+
57
+ # Strips only <a> tags, keeping their visible text and other markup.
58
+ def link
59
+ @link ||= namespace::LinkSanitizer.new
60
+ end
61
+ end
62
+ end
63
+ end
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.11.1".freeze
2
+ VERSION = "1.12.1".freeze
3
3
  end
@@ -12,6 +12,7 @@ require "concerns_on_rails/support/column_guard"
12
12
  require "concerns_on_rails/support/random_value"
13
13
  require "concerns_on_rails/support/address_data"
14
14
  require "concerns_on_rails/support/sequence_calculator"
15
+ require "concerns_on_rails/support/html_sanitizers"
15
16
 
16
17
  # Model concerns
17
18
  require "concerns_on_rails/models/sluggable"
@@ -28,6 +29,8 @@ require "concerns_on_rails/models/tokenizable"
28
29
  require "concerns_on_rails/models/stateable"
29
30
  require "concerns_on_rails/models/addressable"
30
31
  require "concerns_on_rails/models/sequenceable"
32
+ require "concerns_on_rails/models/taggable"
33
+ require "concerns_on_rails/models/sanitizable"
31
34
 
32
35
  # Controller concerns
33
36
  require "concerns_on_rails/controllers/paginatable"
@@ -36,6 +39,7 @@ require "concerns_on_rails/controllers/sortable"
36
39
  require "concerns_on_rails/controllers/respondable"
37
40
  require "concerns_on_rails/controllers/error_handleable"
38
41
  require "concerns_on_rails/controllers/includable"
42
+ require "concerns_on_rails/controllers/secure_headable"
39
43
 
40
44
  # Backwards compatibility (top-level aliases for pre-1.6 module paths)
41
45
  require "concerns_on_rails/legacy_aliases"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: concerns_on_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.11.1
4
+ version: 1.12.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-05 00:00:00.000000000 Z
11
+ date: 2026-06-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -75,6 +75,7 @@ files:
75
75
  - lib/concerns_on_rails/controllers/includable.rb
76
76
  - lib/concerns_on_rails/controllers/paginatable.rb
77
77
  - lib/concerns_on_rails/controllers/respondable.rb
78
+ - lib/concerns_on_rails/controllers/secure_headable.rb
78
79
  - lib/concerns_on_rails/controllers/sortable.rb
79
80
  - lib/concerns_on_rails/legacy_aliases.rb
80
81
  - lib/concerns_on_rails/models/activatable.rb
@@ -83,6 +84,7 @@ files:
83
84
  - lib/concerns_on_rails/models/hashable.rb
84
85
  - lib/concerns_on_rails/models/normalizable.rb
85
86
  - lib/concerns_on_rails/models/publishable.rb
87
+ - lib/concerns_on_rails/models/sanitizable.rb
86
88
  - lib/concerns_on_rails/models/schedulable.rb
87
89
  - lib/concerns_on_rails/models/searchable.rb
88
90
  - lib/concerns_on_rails/models/sequenceable.rb
@@ -90,9 +92,11 @@ files:
90
92
  - lib/concerns_on_rails/models/soft_deletable.rb
91
93
  - lib/concerns_on_rails/models/sortable.rb
92
94
  - lib/concerns_on_rails/models/stateable.rb
95
+ - lib/concerns_on_rails/models/taggable.rb
93
96
  - lib/concerns_on_rails/models/tokenizable.rb
94
97
  - lib/concerns_on_rails/support/address_data.rb
95
98
  - lib/concerns_on_rails/support/column_guard.rb
99
+ - lib/concerns_on_rails/support/html_sanitizers.rb
96
100
  - lib/concerns_on_rails/support/random_value.rb
97
101
  - lib/concerns_on_rails/support/sequence_calculator.rb
98
102
  - lib/concerns_on_rails/version.rb