concerns_on_rails 1.11.2 → 1.13.0

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: 9eb1080168652222770c2ea933745175091c64599ea39e309220a9be8b241b1c
4
- data.tar.gz: f39962ffb8a0e359d2137f1df9440be0a58a17d8c05d78181ba17d2ae1d0433b
3
+ metadata.gz: 4b5bc2f3b03563d643ff2e9cffe13038cf90482499d88475ef49fea9d37ea5f9
4
+ data.tar.gz: d4b3228e473a838929c108d548674cb7b17738c62af1e1dee7454651f405e68c
5
5
  SHA512:
6
- metadata.gz: ee0491e22fe01259415dbc266d19b9a2e966c53e9f0a0b3acded0aa5dd60e2e7085faeeff0b7995e6d6e1849612f1611a551bd2dbb5837f2757cb0de25ebfb72
7
- data.tar.gz: 6297b08086c0b8417eff076393d725e40d6e469780fb8d60e5525a1b3a3a53d57fa44a6e90457f4625a7ed7012494cb80d385e5138c8b6e8b7d4836e0ed79cdd
6
+ metadata.gz: 9f860c2f0c0a6d7570985c6a0f13c5b923ddb7aa12bcb08060b9dbbcd7ee6106182679d99ac3e7826835d8ccea708ef8bc82101aeb6c142fe20cbfbac77f636b
7
+ data.tar.gz: 865441dcf91d87c664809919a930ac2aedfa27fce106570a77e0dcf2331cc074313d0f63abecd21691b36c3af5861c72cc21b956ed70e11896ef411737287671
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.13.0 (2026-06-06)
4
+
5
+ ### Added
6
+ - **Models::Maskable**: non-destructive display masking for sensitive attributes. `maskable :email, with: :email` adds a `masked_<field>` reader and never writes the column (the raw value stays in the DB). Presets `:email` / `:phone` / `:credit_card` / `:last4` / `:all`, a configurable `mask:` character, and a `Proc` escape hatch. Backed by `Support::Masker`; complements `Sanitizable`.
7
+ - **Models::Monetizable**: exact, float-free money handling over an integer subunit column. `monetizable :price_cents` derives `price` / `price=` / `formatted_price` (the `_cents` suffix is stripped, or name them with `as:`). Options `unit:` / `precision:` / `delimiter:` / `separator:` / `subunit_to_unit:`. Uses `BigDecimal` throughout; backed by `Support::Money`.
8
+ - **Controllers::Localizable**: per-request locale selection from `params` and/or the `Accept-Language` header via an `around_action` (`I18n.with_locale`). `localizable available: %i[en fr], default: :en`; options `param:` / `header:`. The resolved locale is always validated against `I18n.available_locales`, so it can never raise `I18n::InvalidLocale`.
9
+
10
+ ### Notes
11
+ - All changes are additive and backward-compatible, with zero new runtime dependencies (BigDecimal and I18n already ship with the existing stack). `ConcernsOnRails::Maskable` / `ConcernsOnRails::Monetizable` are aliased to their `Models::*` modules; the controller concern stays namespace-only (`ConcernsOnRails::Controllers::Localizable`).
12
+
13
+ ## 1.12.1 (2026-06-06)
14
+
15
+ ### Added
16
+ - **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.
17
+ - **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.
18
+ - **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.
19
+
20
+ ### Notes
21
+ - 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.
22
+ - 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.
23
+
3
24
  ## 1.11.2 (2026-06-06)
4
25
 
5
26
  ### Added
data/README.md CHANGED
@@ -40,6 +40,9 @@ Article.published.without_deleted.find("hello-world")
40
40
  - [Stateable](#-stateable) — lightweight string-backed state machine
41
41
  - [Addressable](#-addressable) — postal address normalization + format validation
42
42
  - [Taggable](#-taggable) — lightweight tagging over a single column
43
+ - [Sanitizable](#-sanitizable) — opt-in HTML sanitization (XSS defense-in-depth)
44
+ - [Maskable](#-maskable) — non-destructive display masking of sensitive fields
45
+ - [Monetizable](#-monetizable) — integer-cents money columns (BigDecimal)
43
46
  - **Controller concerns**
44
47
  - [Paginatable](#-paginatable) — offset pagination with headers
45
48
  - [Filterable](#-filterable) — declarative URL-param filters
@@ -47,6 +50,8 @@ Article.published.without_deleted.find("hello-world")
47
50
  - [Respondable](#-respondable) — standardized JSON envelopes
48
51
  - [ErrorHandleable](#-errorhandleable) — JSON `rescue_from` handlers for common controller errors
49
52
  - [Includable](#-includable) — whitelisted association sideloading + sparse fieldsets
53
+ - [SecureHeadable](#-secureheadable) — security response headers + native CSP DSL
54
+ - [Localizable](#-localizable) — per-request locale from params / Accept-Language
50
55
  - [Module paths & namespacing](#-module-paths--namespacing)
51
56
  - [Development](#-development)
52
57
  - [Contributing](#-contributing)
@@ -56,7 +61,7 @@ Article.published.without_deleted.find("hello-world")
56
61
 
57
62
  ## ✨ Why this gem?
58
63
 
59
- - **Fifteen model concerns + six controller concerns**, all production-ready
64
+ - **Eighteen model concerns + eight controller concerns**, all production-ready
60
65
  - **One include, one macro** — no boilerplate, no glue code
61
66
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
62
67
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
@@ -827,6 +832,106 @@ Article.all_tags # => sorted unique tags in use
827
832
 
828
833
  ---
829
834
 
835
+ ## 🧼 Sanitizable
836
+
837
+ 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.
838
+
839
+ ```ruby
840
+ class Article < ApplicationRecord
841
+ include ConcernsOnRails::Sanitizable
842
+
843
+ # DEFAULT (on: :read) — non-destructive. The column stays raw; a
844
+ # `sanitized_<field>` reader returns the cleaned value:
845
+ sanitizable :body, with: :safe_list # => article.sanitized_body
846
+ sanitizable :summary, with: :strip # => article.sanitized_summary
847
+ sanitizable :body, with: { tags: %w[b i a], attributes: %w[href] }
848
+
849
+ # EXPLICIT destructive opt-in — for plain-text-only columns only:
850
+ sanitizable :title, with: :strip, on: :write # overwrites in before_validation
851
+ end
852
+
853
+ article = Article.new(body: "<b>Hi</b><script>alert(1)</script>")
854
+ article.body # => "<b>Hi</b><script>alert(1)</script>" (raw, intact)
855
+ article.sanitized_body # => "<b>Hi</b>alert(1)" (script tag dropped)
856
+ ```
857
+
858
+ **Presets** (`with:`)
859
+
860
+ | Preset | Behavior |
861
+ |--------------|------------------------------------------------------------------------|
862
+ | `:strip` | Remove all tags, keep inner text (the default). |
863
+ | `:safe_list` | Rails' allow-list: keep formatting tags, drop `<script>` / `<iframe>`. |
864
+ | `:no_links` | Strip only `<a>` tags, keep their text. |
865
+ | `:none` | No-op (declare the field / reader without transforming). |
866
+ | `Array` | Custom tag allow-list, e.g. `with: %w[b i a]`. |
867
+ | `Hash` | `{ tags: [...], attributes: [...] }` allow-list. |
868
+ | `Proc` | Used as-is (you own the non-String guard). |
869
+
870
+ **Notes**
871
+ - `on: :read` (default) is **non-destructive**: it adds a `sanitized_<field>` reader and leaves the stored column untouched.
872
+ - `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.
873
+ - For full user-authored rich text, prefer [Action Text](https://guides.rubyonrails.org/action_text_overview.html).
874
+
875
+ ---
876
+
877
+ ## 🙈 Maskable
878
+
879
+ Non-destructive display masking for sensitive attributes. Each declaration adds a `masked_<field>` reader and **never writes the column** — the raw value stays in the database (masking is a presentation concern). Dependency-free.
880
+
881
+ ```ruby
882
+ class User < ApplicationRecord
883
+ include ConcernsOnRails::Maskable
884
+
885
+ maskable :email, with: :email # => user.masked_email "j****@example.com"
886
+ maskable :card, with: :credit_card # => user.masked_card "**** **** **** 4242"
887
+ maskable :ssn, with: :last4, mask: "•"
888
+ maskable :token, with: ->(v) { "#{v.to_s[0, 3]}…" }
889
+ end
890
+ ```
891
+
892
+ **Presets** (`with:`)
893
+
894
+ | Preset | Result |
895
+ |----------------|---------------------------------------------|
896
+ | `:email` | `j****@example.com` (first char + domain) |
897
+ | `:phone` | `***-2671` (last 4 digits) |
898
+ | `:credit_card` | `**** **** **** 4242` (last 4 digits) |
899
+ | `:last4` | keep the last 4 characters |
900
+ | `:all` | mask every character (the default) |
901
+ | `Proc` | used as-is (you own the non-String guard) |
902
+
903
+ `mask:` sets the mask character (default `*`). Nil and non-string values pass through untouched. To strip dangerous HTML instead, see [Sanitizable](#-sanitizable).
904
+
905
+ ---
906
+
907
+ ## 💰 Monetizable
908
+
909
+ Money handling for an integer "subunit" column (e.g. cents) — exact and float-free via `BigDecimal`. `monetizable :price_cents` derives three methods (the `_cents` suffix is stripped):
910
+
911
+ ```ruby
912
+ class Product < ApplicationRecord
913
+ include ConcernsOnRails::Monetizable
914
+
915
+ monetizable :price_cents # => price / price= / formatted_price
916
+ monetizable :shipping_cents, as: :shipping
917
+ monetizable :total_cents, unit: "€", delimiter: ".", separator: ","
918
+ end
919
+
920
+ product.price = 19.99 # stores price_cents = 1999 (rounded to whole cents)
921
+ product.price # => BigDecimal 19.99
922
+ product.formatted_price # => "$19.99"
923
+ ```
924
+
925
+ | Method | Returns |
926
+ |-------------------|-----------------------------------------------|
927
+ | `price` | the amount as a `BigDecimal` (cents ÷ 100) |
928
+ | `price=` | assign in major units; rounded to whole cents |
929
+ | `formatted_price` | a display string (`"$1,234.56"`) |
930
+
931
+ **Options**: `as:` (explicit method name — required when the column does not end in `_cents`), `unit:` (`"$"`), `precision:` (`2`), `delimiter:` (`","`), `separator:` (`"."`), `subunit_to_unit:` (`100`). `nil` stays `nil` across all three methods.
932
+
933
+ ---
934
+
830
935
  # 🎮 Controller Concerns
831
936
 
832
937
  Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
@@ -1050,6 +1155,65 @@ GET /articles?include=author,comments&fields[articles]=id,title&fields[authors]=
1050
1155
 
1051
1156
  ---
1052
1157
 
1158
+ ## 🛡️ SecureHeadable
1159
+
1160
+ 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.
1161
+
1162
+ ```ruby
1163
+ class ApplicationController < ActionController::Base
1164
+ include ConcernsOnRails::Controllers::SecureHeadable
1165
+
1166
+ # Preset headers, plus any custom "Header-Name" => "value" pairs:
1167
+ secure_headers :nosniff, :sameorigin_frame, :no_referrer_leak, :disable_legacy_xss
1168
+ secure_headers "Permissions-Policy" => "geolocation=()"
1169
+
1170
+ # Delegates to Rails' native CSP DSL — roll out report-only FIRST:
1171
+ content_security_policy_for(report_only: true) do |policy|
1172
+ policy.default_src :self
1173
+ policy.script_src :self
1174
+ policy.object_src :none
1175
+ end
1176
+ end
1177
+ ```
1178
+
1179
+ **Header presets** (`secure_headers`)
1180
+
1181
+ | Preset | Header |
1182
+ |-----------------------|--------------------------------------------------------|
1183
+ | `:nosniff` | `X-Content-Type-Options: nosniff` |
1184
+ | `:sameorigin_frame` | `X-Frame-Options: SAMEORIGIN` |
1185
+ | `:deny_frame` | `X-Frame-Options: DENY` |
1186
+ | `:no_referrer_leak` | `Referrer-Policy: strict-origin-when-cross-origin` |
1187
+ | `:no_cross_domain` | `X-Permitted-Cross-Domain-Policies: none` |
1188
+ | `:disable_legacy_xss` | `X-XSS-Protection: 0` (the only correct modern value) |
1189
+
1190
+ **Notes**
1191
+ - Headers are applied in an `after_action`, so they reinforce Rails' middleware defaults; later `secure_headers` declarations win on a colliding name.
1192
+ - `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.
1193
+ - CSP nonce generation (`content_security_policy_nonce_generator`) is app-wide initializer config and intentionally stays out of the concern.
1194
+ - These headers mitigate clickjacking / MIME-sniffing and (via CSP) XSS as **defense-in-depth** — output escaping remains the primary defense.
1195
+
1196
+ ---
1197
+
1198
+ ## 🌐 Localizable
1199
+
1200
+ Per-request locale selection from the request params and/or the `Accept-Language` header, wrapped in an `around_action` so `I18n.locale` is set for the action and restored afterwards. Dependency-free.
1201
+
1202
+ ```ruby
1203
+ class ApplicationController < ActionController::Base
1204
+ include ConcernsOnRails::Controllers::Localizable
1205
+
1206
+ localizable available: %i[en fr de], default: :en
1207
+ # localizable param: :lang, header: false # params[:lang] only
1208
+ end
1209
+ ```
1210
+
1211
+ Resolution order: `params[param]` → first match in `Accept-Language` → `default` → `I18n.default_locale`. The chosen locale is always validated against `I18n.available_locales`, so a stray param or a mismatched `available:` list can never raise `I18n::InvalidLocale`.
1212
+
1213
+ **Options**: `available:` (allow-list for matching; defaults to `I18n.available_locales`), `default:`, `param:` (default `:locale`), `header:` (default `true`).
1214
+
1215
+ ---
1216
+
1053
1217
  ## 🗂️ Module paths & namespacing
1054
1218
 
1055
1219
  Every concern is available under two paths:
@@ -0,0 +1,96 @@
1
+ require "active_support/concern"
2
+
3
+ module ConcernsOnRails
4
+ module Controllers
5
+ # Per-request locale selection from the request params and/or the
6
+ # `Accept-Language` header, wrapped in an `around_action` so `I18n.locale`
7
+ # is set for the action and restored afterwards.
8
+ #
9
+ # class ApplicationController < ActionController::Base
10
+ # include ConcernsOnRails::Controllers::Localizable
11
+ #
12
+ # localizable available: %i[en fr de], default: :en
13
+ # # localizable param: :lang, header: false # params[:lang] only
14
+ # end
15
+ #
16
+ # Resolution order: `params[param]` → first match in `Accept-Language` →
17
+ # `default` → `I18n.default_locale`. The chosen locale is always validated
18
+ # against `I18n.available_locales` before use, so a stray param or a
19
+ # mismatched `available:` list can never raise `I18n::InvalidLocale`.
20
+ #
21
+ # Options: `available:` (allow-list for param/header matching; defaults to
22
+ # `I18n.available_locales`), `default:`, `param:` (default `:locale`),
23
+ # `header:` (default `true`).
24
+ module Localizable
25
+ extend ActiveSupport::Concern
26
+
27
+ included do
28
+ class_attribute :localizable_options, instance_accessor: false, default: {}
29
+ around_action :switch_locale
30
+ end
31
+
32
+ class_methods do
33
+ def localizable(available: nil, default: nil, param: :locale, header: true)
34
+ self.localizable_options = {
35
+ available: available&.map(&:to_sym),
36
+ default: default&.to_sym,
37
+ param: param&.to_sym,
38
+ header: header
39
+ }
40
+ end
41
+ end
42
+
43
+ # Public so subclasses can override; runs the action under the resolved locale.
44
+ def switch_locale(&)
45
+ I18n.with_locale(resolved_locale, &)
46
+ end
47
+
48
+ # The locale chosen for this request — always one I18n can switch to.
49
+ def resolved_locale
50
+ opts = self.class.localizable_options
51
+ allowed = opts[:available].presence || I18n.available_locales
52
+ candidate = locale_from_param(opts, allowed) || locale_from_header(opts, allowed) || opts[:default]
53
+
54
+ candidate && I18n.available_locales.include?(candidate.to_sym) ? candidate.to_sym : I18n.default_locale
55
+ end
56
+
57
+ private
58
+
59
+ def locale_from_param(opts, allowed)
60
+ return nil unless opts[:param] && respond_to?(:params) && params
61
+
62
+ match_locale(params[opts[:param]], allowed)
63
+ end
64
+
65
+ def locale_from_header(opts, allowed)
66
+ return nil unless opts[:header]
67
+
68
+ header = accept_language_header
69
+ header.blank? ? nil : parse_accept_language(header, allowed)
70
+ end
71
+
72
+ def accept_language_header
73
+ return nil unless respond_to?(:request)
74
+
75
+ req = request
76
+ req.respond_to?(:headers) ? req.headers["Accept-Language"] : nil
77
+ end
78
+
79
+ def parse_accept_language(header, allowed)
80
+ header.split(",").each do |part|
81
+ lang = part.split(";").first.to_s.strip.split("-").first
82
+ match = match_locale(lang, allowed)
83
+ return match if match
84
+ end
85
+ nil
86
+ end
87
+
88
+ def match_locale(candidate, allowed)
89
+ return nil if candidate.blank?
90
+
91
+ wanted = candidate.to_s.downcase
92
+ allowed.find { |loc| loc.to_s.downcase == wanted }
93
+ end
94
+ end
95
+ end
96
+ end
@@ -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
@@ -17,4 +17,7 @@ module ConcernsOnRails
17
17
  Addressable = Models::Addressable
18
18
  Sequenceable = Models::Sequenceable
19
19
  Taggable = Models::Taggable
20
+ Sanitizable = Models::Sanitizable
21
+ Maskable = Models::Maskable
22
+ Monetizable = Models::Monetizable
20
23
  end
@@ -0,0 +1,79 @@
1
+ require "active_support/concern"
2
+ require "concerns_on_rails/support/masker"
3
+
4
+ module ConcernsOnRails
5
+ module Models
6
+ # Non-destructive display masking for sensitive string attributes.
7
+ #
8
+ # Masking is ALWAYS read-only: each declaration adds a `masked_<field>`
9
+ # reader and never writes the stored column (the raw value stays in the DB,
10
+ # because masking is a presentation concern). For stripping dangerous HTML
11
+ # see Models::Sanitizable.
12
+ #
13
+ # class User < ApplicationRecord
14
+ # include ConcernsOnRails::Models::Maskable
15
+ #
16
+ # maskable :email, with: :email # => user.masked_email "j****@example.com"
17
+ # maskable :card, with: :credit_card # => user.masked_card "**** **** **** 4242"
18
+ # maskable :ssn, with: :last4, mask: "•"
19
+ # maskable :token, with: ->(v) { "#{v.to_s[0, 3]}…" }
20
+ # end
21
+ #
22
+ # Presets (the `with:` argument):
23
+ # :email — mask the local part, keep first char + domain
24
+ # :phone — keep the last 4 digits ("***-2671")
25
+ # :credit_card — keep the last 4 digits ("**** **** **** 4242")
26
+ # :last4 — keep the last 4 characters
27
+ # :all — mask every character (the default)
28
+ # Proc — used as-is (the caller owns the non-String guard)
29
+ #
30
+ # `mask:` sets the mask character (default "*") for the preset forms.
31
+ module Maskable
32
+ extend ActiveSupport::Concern
33
+
34
+ PRESETS = %i[email phone credit_card last4 all].freeze
35
+
36
+ included do
37
+ class_attribute :maskable_rules, instance_accessor: false, default: {}
38
+ end
39
+
40
+ class_methods do
41
+ include ConcernsOnRails::Support::ColumnGuard
42
+
43
+ def maskable(*fields, with: :all, mask: "*")
44
+ raise ArgumentError, "ConcernsOnRails::Models::Maskable: at least one field is required" if fields.empty?
45
+
46
+ masker = resolve_masker(with, mask)
47
+ ensure_columns!("ConcernsOnRails::Models::Maskable", fields)
48
+
49
+ fields.each do |field|
50
+ key = field.to_sym
51
+ self.maskable_rules = maskable_rules.merge(key => masker)
52
+ define_method("masked_#{field}") { masker.call(self[key]) }
53
+ end
54
+ end
55
+ end
56
+
57
+ class_methods do
58
+ private
59
+
60
+ def resolve_masker(with, mask)
61
+ case with
62
+ when Symbol
63
+ unless PRESETS.include?(with)
64
+ raise ArgumentError,
65
+ "ConcernsOnRails::Models::Maskable: unknown preset '#{with}'. " \
66
+ "Valid presets: #{PRESETS.join(', ')}"
67
+ end
68
+
69
+ ->(v) { ConcernsOnRails::Support::Masker.public_send(with, v, mask: mask) }
70
+ when Proc then with
71
+ else
72
+ raise ArgumentError,
73
+ "ConcernsOnRails::Models::Maskable: :with must be a preset symbol or a Proc/lambda, got #{with.class}"
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,89 @@
1
+ require "active_support/concern"
2
+ require "concerns_on_rails/support/money"
3
+
4
+ module ConcernsOnRails
5
+ module Models
6
+ # Money handling for an integer "subunit" column (e.g. cents) — exact,
7
+ # float-free, via BigDecimal.
8
+ #
9
+ # Declaring `monetizable :price_cents` adds three methods derived from the
10
+ # column name (the `_cents` suffix is stripped):
11
+ # * `price` — the amount as a BigDecimal (cents / 100)
12
+ # * `price=` — assign in major units; rounded to whole cents
13
+ # * `formatted_price` — a display string ("$1,234.56")
14
+ #
15
+ # class Product < ApplicationRecord
16
+ # include ConcernsOnRails::Models::Monetizable
17
+ #
18
+ # monetizable :price_cents # => price / price= / formatted_price
19
+ # monetizable :shipping_cents, as: :shipping
20
+ # monetizable :total_cents, unit: "€", separator: ",", delimiter: "."
21
+ # end
22
+ #
23
+ # product.price = 19.99 # stores price_cents = 1999
24
+ # product.price # => 0.1999e2 (BigDecimal 19.99)
25
+ # product.formatted_price # => "$19.99"
26
+ #
27
+ # Options: `as:` (explicit method name — required when the column does not
28
+ # end in `_cents`), `unit:` ("$"), `precision:` (2), `delimiter:` (","),
29
+ # `separator:` ("."), `subunit_to_unit:` (100).
30
+ module Monetizable
31
+ extend ActiveSupport::Concern
32
+
33
+ included do
34
+ class_attribute :monetizable_rules, instance_accessor: false, default: {}
35
+ end
36
+
37
+ class_methods do
38
+ include ConcernsOnRails::Support::ColumnGuard
39
+
40
+ def monetizable(*fields, as: nil, unit: "$", precision: 2, delimiter: ",", separator: ".", subunit_to_unit: 100)
41
+ raise ArgumentError, "ConcernsOnRails::Models::Monetizable: at least one field is required" if fields.empty?
42
+
43
+ raise ArgumentError, "ConcernsOnRails::Models::Monetizable: :as cannot be combined with multiple fields" if as && fields.size > 1
44
+
45
+ ensure_columns!("ConcernsOnRails::Models::Monetizable", fields)
46
+ config = { unit: unit, precision: precision, delimiter: delimiter, separator: separator, subunit_to_unit: subunit_to_unit }
47
+ fields.each { |cents_field| define_money_accessors(cents_field.to_sym, as, config) }
48
+ end
49
+ end
50
+
51
+ class_methods do # rubocop:disable Metrics/BlockLength
52
+ private
53
+
54
+ def define_money_accessors(cents_field, as, config)
55
+ name = money_name(cents_field, as)
56
+ subunit = config[:subunit_to_unit]
57
+ self.monetizable_rules = monetizable_rules.merge(cents_field => name)
58
+
59
+ define_method(name) do
60
+ cents = self[cents_field]
61
+ cents.nil? ? nil : BigDecimal(cents.to_s) / subunit
62
+ end
63
+
64
+ define_method("#{name}=") do |amount|
65
+ self[cents_field] = amount.nil? ? nil : (BigDecimal(amount.to_s) * subunit).round
66
+ end
67
+
68
+ define_method("formatted_#{name}") do
69
+ cents = self[cents_field]
70
+ cents.nil? ? nil : ConcernsOnRails::Support::Money.format(cents, config)
71
+ end
72
+ end
73
+
74
+ def money_name(cents_field, as)
75
+ return as.to_sym if as
76
+
77
+ str = cents_field.to_s
78
+ unless str.end_with?("_cents")
79
+ raise ArgumentError,
80
+ "ConcernsOnRails::Models::Monetizable: cannot derive a money method name from '#{cents_field}' " \
81
+ "(it does not end in '_cents'); pass `as:` to name it explicitly"
82
+ end
83
+
84
+ str.delete_suffix("_cents").to_sym
85
+ end
86
+ end
87
+ end
88
+ end
89
+ 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
@@ -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
@@ -0,0 +1,60 @@
1
+ require "active_support/concern"
2
+
3
+ module ConcernsOnRails
4
+ module Support
5
+ # Display-only value-masking helpers shared by Models::Maskable.
6
+ #
7
+ # Every method is string-safe: a non-String argument is returned untouched,
8
+ # exactly like the Normalizable / Sanitizable preset lambdas. Masking is for
9
+ # presentation only — callers keep the original value in the database.
10
+ module Masker
11
+ module_function
12
+
13
+ DEFAULT_MASK = "*".freeze
14
+
15
+ # Replace every character with the mask character.
16
+ def all(value, mask: DEFAULT_MASK)
17
+ value.is_a?(String) ? mask * value.length : value
18
+ end
19
+
20
+ # Keep only the last four characters visible.
21
+ def last4(value, mask: DEFAULT_MASK)
22
+ return value unless value.is_a?(String)
23
+
24
+ value.length <= 4 ? mask * value.length : (mask * (value.length - 4)) + value[-4..]
25
+ end
26
+
27
+ # Mask the local part of an email, keeping the first character + domain:
28
+ # "john.doe@example.com" => "j*******@example.com"
29
+ def email(value, mask: DEFAULT_MASK)
30
+ return value unless value.is_a?(String)
31
+
32
+ local, at, domain = value.partition("@")
33
+ return value if at.empty? # not an email-shaped string; leave it alone
34
+
35
+ masked_local = local.length <= 1 ? mask : local[0] + (mask * (local.length - 1))
36
+ "#{masked_local}@#{domain}"
37
+ end
38
+
39
+ # Keep the last four digits of a phone number visible: "***-2671".
40
+ def phone(value, mask: DEFAULT_MASK)
41
+ return value unless value.is_a?(String)
42
+
43
+ digits = value.gsub(/\D/, "")
44
+ return value if digits.empty?
45
+
46
+ "#{mask * 3}-#{digits.length <= 4 ? digits : digits[-4..]}"
47
+ end
48
+
49
+ # Keep the last four digits of a card number: "**** **** **** 4242".
50
+ def credit_card(value, mask: DEFAULT_MASK)
51
+ return value unless value.is_a?(String)
52
+
53
+ digits = value.gsub(/\D/, "")
54
+ return all(value, mask: mask) if digits.length <= 4
55
+
56
+ "#{mask * 4} #{mask * 4} #{mask * 4} #{digits[-4..]}"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,38 @@
1
+ require "bigdecimal"
2
+
3
+ module ConcernsOnRails
4
+ module Support
5
+ # Formats an integer subunit amount (e.g. cents) as a human-readable money
6
+ # string. Pure and stateless; used by Models::Monetizable. Uses BigDecimal
7
+ # throughout so there is no binary-float rounding drift.
8
+ module Money
9
+ module_function
10
+
11
+ # format(199999) => "$1,999.99"
12
+ # format(-500, unit: "£") => "-£5.00"
13
+ # format(1234, unit: "¥", precision: 0, subunit_to_unit: 1) => "¥1,234"
14
+ def format(cents, options = {})
15
+ unit = options.fetch(:unit, "$")
16
+ precision = options.fetch(:precision, 2)
17
+ delimiter = options.fetch(:delimiter, ",")
18
+ separator = options.fetch(:separator, ".")
19
+ subunit = options.fetch(:subunit_to_unit, 100)
20
+
21
+ decimal = BigDecimal(cents.to_s) / subunit
22
+ # BigDecimal#round returns an Integer for precision <= 0, so re-wrap it
23
+ # in a BigDecimal before #to_s("F") (Integer#to_s would read "F" as a radix).
24
+ rounded = BigDecimal(decimal.abs.round(precision).to_s)
25
+ whole, _, frac = rounded.to_s("F").partition(".")
26
+ whole = delimit(whole, delimiter)
27
+ number = precision.positive? ? "#{whole}#{separator}#{frac.ljust(precision, '0')[0, precision]}" : whole
28
+
29
+ "#{'-' if decimal.negative?}#{unit}#{number}"
30
+ end
31
+
32
+ # Insert the thousands delimiter into a non-negative integer string.
33
+ def delimit(integer_string, delimiter)
34
+ integer_string.reverse.gsub(/(\d{3})(?=\d)/, "\\1#{delimiter}").reverse
35
+ end
36
+ end
37
+ end
38
+ end
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.11.2".freeze
2
+ VERSION = "1.13.0".freeze
3
3
  end
@@ -12,6 +12,9 @@ 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"
16
+ require "concerns_on_rails/support/masker"
17
+ require "concerns_on_rails/support/money"
15
18
 
16
19
  # Model concerns
17
20
  require "concerns_on_rails/models/sluggable"
@@ -29,6 +32,9 @@ require "concerns_on_rails/models/stateable"
29
32
  require "concerns_on_rails/models/addressable"
30
33
  require "concerns_on_rails/models/sequenceable"
31
34
  require "concerns_on_rails/models/taggable"
35
+ require "concerns_on_rails/models/sanitizable"
36
+ require "concerns_on_rails/models/maskable"
37
+ require "concerns_on_rails/models/monetizable"
32
38
 
33
39
  # Controller concerns
34
40
  require "concerns_on_rails/controllers/paginatable"
@@ -37,6 +43,8 @@ require "concerns_on_rails/controllers/sortable"
37
43
  require "concerns_on_rails/controllers/respondable"
38
44
  require "concerns_on_rails/controllers/error_handleable"
39
45
  require "concerns_on_rails/controllers/includable"
46
+ require "concerns_on_rails/controllers/secure_headable"
47
+ require "concerns_on_rails/controllers/localizable"
40
48
 
41
49
  # Backwards compatibility (top-level aliases for pre-1.6 module paths)
42
50
  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.2
4
+ version: 1.13.0
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
@@ -73,16 +73,21 @@ files:
73
73
  - lib/concerns_on_rails/controllers/error_handleable.rb
74
74
  - lib/concerns_on_rails/controllers/filterable.rb
75
75
  - lib/concerns_on_rails/controllers/includable.rb
76
+ - lib/concerns_on_rails/controllers/localizable.rb
76
77
  - lib/concerns_on_rails/controllers/paginatable.rb
77
78
  - lib/concerns_on_rails/controllers/respondable.rb
79
+ - lib/concerns_on_rails/controllers/secure_headable.rb
78
80
  - lib/concerns_on_rails/controllers/sortable.rb
79
81
  - lib/concerns_on_rails/legacy_aliases.rb
80
82
  - lib/concerns_on_rails/models/activatable.rb
81
83
  - lib/concerns_on_rails/models/addressable.rb
82
84
  - lib/concerns_on_rails/models/expirable.rb
83
85
  - lib/concerns_on_rails/models/hashable.rb
86
+ - lib/concerns_on_rails/models/maskable.rb
87
+ - lib/concerns_on_rails/models/monetizable.rb
84
88
  - lib/concerns_on_rails/models/normalizable.rb
85
89
  - lib/concerns_on_rails/models/publishable.rb
90
+ - lib/concerns_on_rails/models/sanitizable.rb
86
91
  - lib/concerns_on_rails/models/schedulable.rb
87
92
  - lib/concerns_on_rails/models/searchable.rb
88
93
  - lib/concerns_on_rails/models/sequenceable.rb
@@ -94,6 +99,9 @@ files:
94
99
  - lib/concerns_on_rails/models/tokenizable.rb
95
100
  - lib/concerns_on_rails/support/address_data.rb
96
101
  - lib/concerns_on_rails/support/column_guard.rb
102
+ - lib/concerns_on_rails/support/html_sanitizers.rb
103
+ - lib/concerns_on_rails/support/masker.rb
104
+ - lib/concerns_on_rails/support/money.rb
97
105
  - lib/concerns_on_rails/support/random_value.rb
98
106
  - lib/concerns_on_rails/support/sequence_calculator.rb
99
107
  - lib/concerns_on_rails/version.rb