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 +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +156 -4
- data/lib/concerns_on_rails/controllers/secure_headable.rb +105 -0
- data/lib/concerns_on_rails/legacy_aliases.rb +2 -0
- data/lib/concerns_on_rails/models/sanitizable.rb +148 -0
- data/lib/concerns_on_rails/models/sluggable.rb +22 -9
- data/lib/concerns_on_rails/models/soft_deletable.rb +45 -22
- data/lib/concerns_on_rails/models/sortable.rb +12 -3
- data/lib/concerns_on_rails/models/taggable.rb +160 -0
- data/lib/concerns_on_rails/support/html_sanitizers.rb +63 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +4 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 56664d3e955f53dbeec1d586c31b57c860a208cedff76990236a728de1c6f9ec
|
|
4
|
+
data.tar.gz: 6a427597a32d8a2d848d2f51ed9cf45d6a3ea41dd9ea77c2b215b4a66846170e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
- **
|
|
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`).
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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
|
|
38
|
-
# sluggable_by :title, scope: :account_id
|
|
39
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
54
|
-
|
|
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,
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/concerns_on_rails.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|