concerns_on_rails 1.12.1 → 1.14.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 +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +166 -1
- data/lib/concerns_on_rails/controllers/authorizable.rb +124 -0
- data/lib/concerns_on_rails/controllers/localizable.rb +96 -0
- data/lib/concerns_on_rails/controllers/throttleable.rb +159 -0
- data/lib/concerns_on_rails/controllers/timezoneable.rb +129 -0
- data/lib/concerns_on_rails/legacy_aliases.rb +2 -0
- data/lib/concerns_on_rails/models/maskable.rb +79 -0
- data/lib/concerns_on_rails/models/monetizable.rb +89 -0
- data/lib/concerns_on_rails/support/masker.rb +60 -0
- data/lib/concerns_on_rails/support/money.rb +38 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +8 -0
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b5c234af5baa9aa52269898bb63da24e4cc373bc5e2b3d7db7467ef89a86141b
|
|
4
|
+
data.tar.gz: 20cd7d9bb802447d7fd38be485e623216774ffa3dba9d5aae9f225ad48fb0b8a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ebef3ed5411bf6fa388b9a1d47ab92eb9a871eeda6e478aee4dc0dfd586a10bcbf05497dba93325b896a897a0a0cc3a296ce833aaef72343decacce42c0087aa
|
|
7
|
+
data.tar.gz: efc7d26aca5aeff71308135c2617374d98ee8c0e955038a783e2b3c8c2b14a1827ae27fd2a7c64d6637e6c2fe61b2711da796f388e172bdc9dac827dc56e6743
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.14.0 (2026-06-06)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Controllers::Authorizable**: declarative, block-only per-action authorization gate. `authorize_by { current_user.admin? }` (arity-safe — also `|action|` / `|action, user|`) halts the first failing rule with 403; `require_role :admin, :editor, only: :publish` is sugar over the common role check. `only:` / `except:` scope a rule to a subset of actions. When `Respondable` is included the denial delegates to `render_error` (`code: "forbidden"`), otherwise the same envelope is rendered inline. Deliberately not a policy/ability framework (no policy objects, no ability DSL, no resource inference). Zero new runtime dependencies.
|
|
7
|
+
- **Controllers::Throttleable**: per-request rate limiting with a store-agnostic, injectable backend. `throttle_by limit: 100, period: 1.minute` (bucketed per-IP by default, or by any `by:` lambda) halts an over-limit request with 429 plus `Retry-After` and `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset`. Fixed-window counter keyed by a floored time bucket; requires an explicit atomic store (`self.throttleable_store = Rails.cache`) — there is no silent in-process default, so the first throttled request raises until one is configured. Backports the essentials of Rails 7.2's `rate_limit` (with standardized headers) to Rails 5.0+. Zero new runtime dependencies.
|
|
8
|
+
- **Controllers::Timezoneable**: per-request `Time.zone` selection via an `around_action` (`Time.use_zone`) — the time analogue of `Controllers::Localizable`. `timezoneable available: [...], default: "UTC"` resolves from `params` → `Time-Zone` header → cookie → default, every candidate validated through `ActiveSupport::TimeZone[...]` so it can never raise at request time; an unknown configured zone fails fast at declaration. Options `param:` / `header:` / `cookie:`. Zero new runtime dependencies.
|
|
9
|
+
|
|
10
|
+
### Notes
|
|
11
|
+
- All changes are additive and backward-compatible, with zero new runtime dependencies. The three new controller concerns stay namespace-only (`ConcernsOnRails::Controllers::*`), matching the existing controller concerns.
|
|
12
|
+
|
|
13
|
+
## 1.13.0 (2026-06-06)
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **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`.
|
|
17
|
+
- **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`.
|
|
18
|
+
- **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`.
|
|
19
|
+
|
|
20
|
+
### Notes
|
|
21
|
+
- 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`).
|
|
22
|
+
|
|
3
23
|
## 1.12.1 (2026-06-06)
|
|
4
24
|
|
|
5
25
|
### Added
|
data/README.md
CHANGED
|
@@ -41,6 +41,8 @@ Article.published.without_deleted.find("hello-world")
|
|
|
41
41
|
- [Addressable](#-addressable) — postal address normalization + format validation
|
|
42
42
|
- [Taggable](#-taggable) — lightweight tagging over a single column
|
|
43
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)
|
|
44
46
|
- **Controller concerns**
|
|
45
47
|
- [Paginatable](#-paginatable) — offset pagination with headers
|
|
46
48
|
- [Filterable](#-filterable) — declarative URL-param filters
|
|
@@ -49,6 +51,10 @@ Article.published.without_deleted.find("hello-world")
|
|
|
49
51
|
- [ErrorHandleable](#-errorhandleable) — JSON `rescue_from` handlers for common controller errors
|
|
50
52
|
- [Includable](#-includable) — whitelisted association sideloading + sparse fieldsets
|
|
51
53
|
- [SecureHeadable](#-secureheadable) — security response headers + native CSP DSL
|
|
54
|
+
- [Localizable](#-localizable) — per-request locale from params / Accept-Language
|
|
55
|
+
- [Authorizable](#-authorizable) — per-action 403 authorization gate (block-based)
|
|
56
|
+
- [Throttleable](#-throttleable) — rate limiting with 429 + `X-RateLimit-*` headers
|
|
57
|
+
- [Timezoneable](#-timezoneable) — per-request `Time.zone` from params / header / cookie
|
|
52
58
|
- [Module paths & namespacing](#-module-paths--namespacing)
|
|
53
59
|
- [Development](#-development)
|
|
54
60
|
- [Contributing](#-contributing)
|
|
@@ -58,7 +64,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
58
64
|
|
|
59
65
|
## ✨ Why this gem?
|
|
60
66
|
|
|
61
|
-
- **
|
|
67
|
+
- **Eighteen model concerns + eight controller concerns**, all production-ready
|
|
62
68
|
- **One include, one macro** — no boilerplate, no glue code
|
|
63
69
|
- **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
|
|
64
70
|
- **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
|
|
@@ -871,6 +877,64 @@ article.sanitized_body # => "<b>Hi</b>alert(1)" (script tag
|
|
|
871
877
|
|
|
872
878
|
---
|
|
873
879
|
|
|
880
|
+
## 🙈 Maskable
|
|
881
|
+
|
|
882
|
+
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.
|
|
883
|
+
|
|
884
|
+
```ruby
|
|
885
|
+
class User < ApplicationRecord
|
|
886
|
+
include ConcernsOnRails::Maskable
|
|
887
|
+
|
|
888
|
+
maskable :email, with: :email # => user.masked_email "j****@example.com"
|
|
889
|
+
maskable :card, with: :credit_card # => user.masked_card "**** **** **** 4242"
|
|
890
|
+
maskable :ssn, with: :last4, mask: "•"
|
|
891
|
+
maskable :token, with: ->(v) { "#{v.to_s[0, 3]}…" }
|
|
892
|
+
end
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
**Presets** (`with:`)
|
|
896
|
+
|
|
897
|
+
| Preset | Result |
|
|
898
|
+
|----------------|---------------------------------------------|
|
|
899
|
+
| `:email` | `j****@example.com` (first char + domain) |
|
|
900
|
+
| `:phone` | `***-2671` (last 4 digits) |
|
|
901
|
+
| `:credit_card` | `**** **** **** 4242` (last 4 digits) |
|
|
902
|
+
| `:last4` | keep the last 4 characters |
|
|
903
|
+
| `:all` | mask every character (the default) |
|
|
904
|
+
| `Proc` | used as-is (you own the non-String guard) |
|
|
905
|
+
|
|
906
|
+
`mask:` sets the mask character (default `*`). Nil and non-string values pass through untouched. To strip dangerous HTML instead, see [Sanitizable](#-sanitizable).
|
|
907
|
+
|
|
908
|
+
---
|
|
909
|
+
|
|
910
|
+
## 💰 Monetizable
|
|
911
|
+
|
|
912
|
+
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):
|
|
913
|
+
|
|
914
|
+
```ruby
|
|
915
|
+
class Product < ApplicationRecord
|
|
916
|
+
include ConcernsOnRails::Monetizable
|
|
917
|
+
|
|
918
|
+
monetizable :price_cents # => price / price= / formatted_price
|
|
919
|
+
monetizable :shipping_cents, as: :shipping
|
|
920
|
+
monetizable :total_cents, unit: "€", delimiter: ".", separator: ","
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
product.price = 19.99 # stores price_cents = 1999 (rounded to whole cents)
|
|
924
|
+
product.price # => BigDecimal 19.99
|
|
925
|
+
product.formatted_price # => "$19.99"
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
| Method | Returns |
|
|
929
|
+
|-------------------|-----------------------------------------------|
|
|
930
|
+
| `price` | the amount as a `BigDecimal` (cents ÷ 100) |
|
|
931
|
+
| `price=` | assign in major units; rounded to whole cents |
|
|
932
|
+
| `formatted_price` | a display string (`"$1,234.56"`) |
|
|
933
|
+
|
|
934
|
+
**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.
|
|
935
|
+
|
|
936
|
+
---
|
|
937
|
+
|
|
874
938
|
# 🎮 Controller Concerns
|
|
875
939
|
|
|
876
940
|
Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
|
|
@@ -1134,6 +1198,107 @@ end
|
|
|
1134
1198
|
|
|
1135
1199
|
---
|
|
1136
1200
|
|
|
1201
|
+
## 🌐 Localizable
|
|
1202
|
+
|
|
1203
|
+
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.
|
|
1204
|
+
|
|
1205
|
+
```ruby
|
|
1206
|
+
class ApplicationController < ActionController::Base
|
|
1207
|
+
include ConcernsOnRails::Controllers::Localizable
|
|
1208
|
+
|
|
1209
|
+
localizable available: %i[en fr de], default: :en
|
|
1210
|
+
# localizable param: :lang, header: false # params[:lang] only
|
|
1211
|
+
end
|
|
1212
|
+
```
|
|
1213
|
+
|
|
1214
|
+
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`.
|
|
1215
|
+
|
|
1216
|
+
**Options**: `available:` (allow-list for matching; defaults to `I18n.available_locales`), `default:`, `param:` (default `:locale`), `header:` (default `true`).
|
|
1217
|
+
|
|
1218
|
+
---
|
|
1219
|
+
|
|
1220
|
+
## 🔒 Authorizable
|
|
1221
|
+
|
|
1222
|
+
A declarative, **block-only** per-action authorization gate. Each rule is a predicate; the first one that applies to the current action and returns falsey halts the request with **403**. Deliberately small — not a Pundit/CanCan replacement.
|
|
1223
|
+
|
|
1224
|
+
```ruby
|
|
1225
|
+
class Api::BaseController < ApplicationController
|
|
1226
|
+
include ConcernsOnRails::Controllers::Authorizable
|
|
1227
|
+
|
|
1228
|
+
authorize_by { current_user.present? } # every action
|
|
1229
|
+
authorize_by(only: %i[update destroy]) { |_action, user| user.admin? }
|
|
1230
|
+
require_role :admin, :editor, only: :publish # role sugar
|
|
1231
|
+
end
|
|
1232
|
+
```
|
|
1233
|
+
|
|
1234
|
+
The predicate runs via `instance_exec`, so `current_user` (and any helper) resolves on the controller. It is **arity-safe** — write it with zero, one (`|action|`), or two (`|action, user|`) parameters.
|
|
1235
|
+
|
|
1236
|
+
**API**
|
|
1237
|
+
|
|
1238
|
+
| Method | Signature |
|
|
1239
|
+
|----------------|--------------------------------------------------------------------------------------------|
|
|
1240
|
+
| `authorize_by` | `authorize_by(only: nil, except: nil, status: :forbidden, message: "Forbidden", &block)` |
|
|
1241
|
+
| `require_role` | `require_role(*roles, via: :current_user, role_method: :role, only:, except:, status:, message:)` |
|
|
1242
|
+
|
|
1243
|
+
**Notes**
|
|
1244
|
+
- Rules run in declaration order; the first failing rule renders and halts.
|
|
1245
|
+
- When `Respondable` is also included, denials delegate to `render_error` (envelope `{ success: false, error: { message:, code: "forbidden" } }`); otherwise the same envelope is rendered inline.
|
|
1246
|
+
- `only:` / `except:` are mutually exclusive (passing both raises `ArgumentError`); `authorize_by` requires a block and `require_role` requires at least one role.
|
|
1247
|
+
- **Non-goals**: no policy objects, no ability DSL, no resource inference — reach for [`pundit`](https://github.com/varvet/pundit) / [`cancancan`](https://github.com/CanCanCommunity/cancancan) when you outgrow a predicate per action.
|
|
1248
|
+
|
|
1249
|
+
---
|
|
1250
|
+
|
|
1251
|
+
## 🚦 Throttleable
|
|
1252
|
+
|
|
1253
|
+
Per-request rate limiting with a **store-agnostic, injectable** backend — no `rack-attack` needed. When a rule's limit is exceeded the request is halted with **429** plus `Retry-After` and `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset`.
|
|
1254
|
+
|
|
1255
|
+
```ruby
|
|
1256
|
+
class Api::BaseController < ApplicationController
|
|
1257
|
+
include ConcernsOnRails::Controllers::Throttleable
|
|
1258
|
+
|
|
1259
|
+
self.throttleable_store = Rails.cache # must support atomic #increment
|
|
1260
|
+
|
|
1261
|
+
throttle_by limit: 100, period: 1.minute # by IP (default)
|
|
1262
|
+
throttle_by limit: 5, period: 1.minute, only: :create,
|
|
1263
|
+
by: -> { current_user&.id || request.remote_ip }
|
|
1264
|
+
end
|
|
1265
|
+
```
|
|
1266
|
+
|
|
1267
|
+
Fixed-window counter: the key embeds a floored time bucket (`epoch / period`) so each window starts clean and `X-RateLimit-Reset` is exact.
|
|
1268
|
+
|
|
1269
|
+
**Options**: `limit:` (positive integer), `period:` (a `Duration` or seconds), `by:` (discriminator lambda, default per-IP), `only:` / `except:` (mutually exclusive action scoping), `name:` (disambiguates the counter key).
|
|
1270
|
+
|
|
1271
|
+
**Notes**
|
|
1272
|
+
- The store MUST support **atomic increment-with-expiry** (`Rails.cache` with `#increment`, or Redis) — a non-atomic store under-counts under concurrency.
|
|
1273
|
+
- There is **no in-process default store** on purpose: the first throttled request raises `ArgumentError` until you set `throttleable_store`, so you never silently rate-limit per-process.
|
|
1274
|
+
- When `Respondable` is included, the 429 body delegates to `render_error` (`code: "rate_limited"`).
|
|
1275
|
+
- Backports the essentials of Rails 7.2's `rate_limit` (with standardized headers) to Rails 5.0+. For richer rules (fail2ban, allow/deny lists, exponential backoff) reach for [`rack-attack`](https://github.com/rack/rack-attack).
|
|
1276
|
+
|
|
1277
|
+
---
|
|
1278
|
+
|
|
1279
|
+
## 🕒 Timezoneable
|
|
1280
|
+
|
|
1281
|
+
Per-request `Time.zone` selection wrapped in an `around_action` (`Time.use_zone`) — the time analogue of [Localizable](#-localizable). Dependency-free.
|
|
1282
|
+
|
|
1283
|
+
```ruby
|
|
1284
|
+
class ApplicationController < ActionController::Base
|
|
1285
|
+
include ConcernsOnRails::Controllers::Timezoneable
|
|
1286
|
+
|
|
1287
|
+
timezoneable available: ["UTC", "Eastern Time (US & Canada)"], default: "UTC"
|
|
1288
|
+
# timezoneable param: :tz, header: false, cookie: :time_zone
|
|
1289
|
+
end
|
|
1290
|
+
```
|
|
1291
|
+
|
|
1292
|
+
Resolution order: `params[param]` → `Time-Zone` header → cookie (if enabled) → `default` → the current `Time.zone`. Every value — the configured `available:` / `default:` **and** each request candidate — is resolved through `ActiveSupport::TimeZone[...]`, so a zone accepted at boot can never be rejected at request time.
|
|
1293
|
+
|
|
1294
|
+
**Options**: `available:` (allow-list applied to param/header/cookie matching; `default:` bypasses it, mirroring Localizable), `default:`, `param:` (default `:time_zone`), `header:` (default `true`, reads the `Time-Zone` header), `cookie:` (default `false`; `true` reads the `:time_zone` cookie, or pass a cookie name).
|
|
1295
|
+
|
|
1296
|
+
**Notes**
|
|
1297
|
+
- An unknown `available:` / `default:` zone raises `ArgumentError` at declaration time (fail-fast on misconfiguration).
|
|
1298
|
+
- Pairs naturally with the model concerns that read the clock (`Schedulable`, `Publishable`, `Expirable`, `SoftDeletable`).
|
|
1299
|
+
|
|
1300
|
+
---
|
|
1301
|
+
|
|
1137
1302
|
## 🗂️ Module paths & namespacing
|
|
1138
1303
|
|
|
1139
1304
|
Every concern is available under two paths:
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module ConcernsOnRails
|
|
4
|
+
module Controllers
|
|
5
|
+
# Declarative, block-only per-action authorization gate. Each rule is a
|
|
6
|
+
# predicate; the first rule that applies to the current action and returns a
|
|
7
|
+
# falsey value halts the request with 403 (rendered via Respondable's
|
|
8
|
+
# `render_error` when available, otherwise an inline envelope).
|
|
9
|
+
#
|
|
10
|
+
# class Api::BaseController < ApplicationController
|
|
11
|
+
# include ConcernsOnRails::Controllers::Authorizable
|
|
12
|
+
#
|
|
13
|
+
# authorize_by { current_user.present? } # every action
|
|
14
|
+
# authorize_by(only: %i[update destroy]) { |_action, user| user.admin? }
|
|
15
|
+
# require_role :admin, :editor, only: :publish # role sugar
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# The block is invoked with `instance_exec` so `current_user` (and any other
|
|
19
|
+
# helper) resolves on the controller. It is arity-safe: write it with zero,
|
|
20
|
+
# one (`|action|`), or two (`|action, user|`) parameters.
|
|
21
|
+
#
|
|
22
|
+
# Non-goals (kept deliberately small): this is NOT a policy/ability framework.
|
|
23
|
+
# No policy objects, no ability DSL, no resource inference — reach for Pundit
|
|
24
|
+
# or CanCanCan when you outgrow a predicate per action.
|
|
25
|
+
module Authorizable
|
|
26
|
+
extend ActiveSupport::Concern
|
|
27
|
+
|
|
28
|
+
included do
|
|
29
|
+
class_attribute :authorizable_rules, instance_accessor: false, default: []
|
|
30
|
+
before_action :enforce_authorization
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
module ClassMethods
|
|
34
|
+
# Register an authorization predicate. `only:`/`except:` scope it to a
|
|
35
|
+
# subset of actions (mutually exclusive). `status:` (default :forbidden)
|
|
36
|
+
# and `message:` control the denial response.
|
|
37
|
+
def authorize_by(only: nil, except: nil, status: :forbidden, message: "Forbidden", &block)
|
|
38
|
+
raise ArgumentError, "ConcernsOnRails::Controllers::Authorizable: a block is required" unless block
|
|
39
|
+
|
|
40
|
+
add_authorization_rule(check: block, only: only, except: except, status: status, message: message)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Sugar for the common "actor must have one of these roles" rule. The
|
|
44
|
+
# actor is read via `via:` (default `:current_user`) and its role via
|
|
45
|
+
# `role_method:` (default `:role`). Implemented as a proc, never a lambda,
|
|
46
|
+
# so arity slicing can't raise.
|
|
47
|
+
def require_role(*roles, via: :current_user, role_method: :role, only: nil, except: nil,
|
|
48
|
+
status: :forbidden, message: "Forbidden")
|
|
49
|
+
raise ArgumentError, "ConcernsOnRails::Controllers::Authorizable: at least one role is required" if roles.empty?
|
|
50
|
+
|
|
51
|
+
wanted = roles.map(&:to_s)
|
|
52
|
+
check = proc do
|
|
53
|
+
actor = respond_to?(via) ? send(via) : nil
|
|
54
|
+
actor.respond_to?(role_method) && wanted.include?(actor.public_send(role_method).to_s)
|
|
55
|
+
end
|
|
56
|
+
add_authorization_rule(check: check, only: only, except: except, status: status, message: message)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def add_authorization_rule(check:, only:, except:, status:, message:)
|
|
62
|
+
raise ArgumentError, "ConcernsOnRails::Controllers::Authorizable: pass either :only or :except, not both" if only && except
|
|
63
|
+
|
|
64
|
+
rule = {
|
|
65
|
+
check: check,
|
|
66
|
+
only: only && Array(only).map(&:to_s),
|
|
67
|
+
except: except && Array(except).map(&:to_s),
|
|
68
|
+
status: status,
|
|
69
|
+
message: message
|
|
70
|
+
}
|
|
71
|
+
self.authorizable_rules = authorizable_rules + [rule]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Public so subclasses can override. Iterates the declared rules in order
|
|
76
|
+
# and denies on the first failing rule that applies to the current action.
|
|
77
|
+
def enforce_authorization
|
|
78
|
+
self.class.authorizable_rules.each do |rule|
|
|
79
|
+
next unless authorization_rule_applies?(rule)
|
|
80
|
+
next if invoke_authorization_check(rule[:check])
|
|
81
|
+
|
|
82
|
+
return authorization_denied(status: rule[:status], message: rule[:message])
|
|
83
|
+
end
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Public override point for how a denial is rendered.
|
|
88
|
+
def authorization_denied(status:, message:)
|
|
89
|
+
return unless respond_to?(:response) && response
|
|
90
|
+
|
|
91
|
+
return render_error(message: message, status: status, code: "forbidden") if respond_to?(:render_error)
|
|
92
|
+
|
|
93
|
+
render json: { success: false, error: { message: message, code: "forbidden" } }, status: status
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def authorization_rule_applies?(rule)
|
|
99
|
+
action = authorization_action_name
|
|
100
|
+
return rule[:only].include?(action) if rule[:only]
|
|
101
|
+
return !rule[:except].include?(action) if rule[:except]
|
|
102
|
+
|
|
103
|
+
true
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Arity-safe: slice the args to the predicate's arity before instance_exec
|
|
107
|
+
# so a zero/one/two-arg block all work. A negative arity (splat/optional)
|
|
108
|
+
# receives every arg.
|
|
109
|
+
def invoke_authorization_check(check)
|
|
110
|
+
args = [authorization_action_name, authorization_actor]
|
|
111
|
+
sliced = check.arity.negative? ? args : args.first(check.arity)
|
|
112
|
+
instance_exec(*sliced, &check)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def authorization_actor
|
|
116
|
+
respond_to?(:current_user) ? current_user : nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def authorization_action_name
|
|
120
|
+
respond_to?(:action_name) ? action_name.to_s : nil
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -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,159 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module ConcernsOnRails
|
|
4
|
+
module Controllers
|
|
5
|
+
# Per-request rate limiting with a store-agnostic, injectable backend. When a
|
|
6
|
+
# rule's limit is exceeded the request is halted with 429 plus
|
|
7
|
+
# `Retry-After` and `X-RateLimit-Limit` / `X-RateLimit-Remaining` /
|
|
8
|
+
# `X-RateLimit-Reset` headers.
|
|
9
|
+
#
|
|
10
|
+
# class Api::BaseController < ApplicationController
|
|
11
|
+
# include ConcernsOnRails::Controllers::Throttleable
|
|
12
|
+
#
|
|
13
|
+
# self.throttleable_store = Rails.cache # must support atomic #increment
|
|
14
|
+
#
|
|
15
|
+
# throttle_by limit: 100, period: 1.minute # by IP (default)
|
|
16
|
+
# throttle_by limit: 5, period: 1.minute, only: :create,
|
|
17
|
+
# by: -> { current_user&.id || request.remote_ip }
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# Fixed-window counter: the key embeds a floored time bucket
|
|
21
|
+
# (`epoch / period`) so each window starts clean and `X-RateLimit-Reset` is
|
|
22
|
+
# exact. The store MUST support atomic increment-with-expiry (`Rails.cache`
|
|
23
|
+
# with `#increment`, or Redis); a non-atomic store under-counts under
|
|
24
|
+
# concurrency. There is no in-process default store on purpose — configure
|
|
25
|
+
# one explicitly or the first throttled request raises ArgumentError.
|
|
26
|
+
module Throttleable
|
|
27
|
+
extend ActiveSupport::Concern
|
|
28
|
+
|
|
29
|
+
# Default discriminator — one counter per client IP. Evaluated with
|
|
30
|
+
# instance_exec on the controller, so `request` resolves normally.
|
|
31
|
+
DEFAULT_DISCRIMINATOR = -> { request.remote_ip }
|
|
32
|
+
|
|
33
|
+
included do
|
|
34
|
+
class_attribute :throttleable_rules, instance_accessor: false, default: []
|
|
35
|
+
class_attribute :throttleable_store, instance_accessor: false, default: nil
|
|
36
|
+
before_action :enforce_throttles
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
module ClassMethods
|
|
40
|
+
# Declare a rate-limit rule. `limit` requests per `period` (a Duration or
|
|
41
|
+
# seconds), bucketed by `by:` (a callable, default per-IP). `only:`/
|
|
42
|
+
# `except:` scope it to a subset of actions (mutually exclusive). `name:`
|
|
43
|
+
# disambiguates the counter key when several rules share a discriminator.
|
|
44
|
+
def throttle_by(limit:, period:, by: nil, only: nil, except: nil, name: nil)
|
|
45
|
+
validate_throttle!(limit: limit, period: period, by: by, only: only, except: except)
|
|
46
|
+
|
|
47
|
+
rule = {
|
|
48
|
+
limit: limit,
|
|
49
|
+
period: period.to_i,
|
|
50
|
+
by: by || DEFAULT_DISCRIMINATOR,
|
|
51
|
+
only: only && Array(only).map(&:to_s),
|
|
52
|
+
except: except && Array(except).map(&:to_s),
|
|
53
|
+
name: (name || "rule#{throttleable_rules.size}").to_s
|
|
54
|
+
}
|
|
55
|
+
self.throttleable_rules = throttleable_rules + [rule]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def validate_throttle!(limit:, period:, by:, only:, except:)
|
|
61
|
+
prefix = "ConcernsOnRails::Controllers::Throttleable"
|
|
62
|
+
raise ArgumentError, "#{prefix}: :limit must be a positive integer" unless positive_integer?(limit)
|
|
63
|
+
raise ArgumentError, "#{prefix}: :period must be a positive duration" unless period.to_i.positive?
|
|
64
|
+
raise ArgumentError, "#{prefix}: :by must be callable" unless callable_or_nil?(by)
|
|
65
|
+
raise ArgumentError, "#{prefix}: pass either :only or :except, not both" if only && except
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def positive_integer?(value)
|
|
69
|
+
value.is_a?(Integer) && value.positive?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def callable_or_nil?(value)
|
|
73
|
+
value.nil? || value.respond_to?(:call)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Public so subclasses can override. Applies each in-scope rule; the first
|
|
78
|
+
# rule that exceeds its limit halts the request with a 429.
|
|
79
|
+
def enforce_throttles
|
|
80
|
+
self.class.throttleable_rules.each do |rule|
|
|
81
|
+
next unless throttle_rule_applies?(rule)
|
|
82
|
+
|
|
83
|
+
result = register_throttle_hit(rule)
|
|
84
|
+
emit_throttle_headers(rule, result)
|
|
85
|
+
|
|
86
|
+
return throttled_response(rule, result) if result[:count] > rule[:limit]
|
|
87
|
+
end
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Public override point for the 429 body.
|
|
92
|
+
def throttled_response(_rule, result)
|
|
93
|
+
return unless respond_to?(:response) && response
|
|
94
|
+
|
|
95
|
+
message = "Rate limit exceeded. Retry in #{result[:retry_after]}s."
|
|
96
|
+
return render_error(message: message, status: :too_many_requests, code: "rate_limited") if respond_to?(:render_error)
|
|
97
|
+
|
|
98
|
+
render json: { success: false, error: { message: message, code: "rate_limited" } }, status: :too_many_requests
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def throttle_rule_applies?(rule)
|
|
104
|
+
action = throttle_action_name
|
|
105
|
+
return rule[:only].include?(action) if rule[:only]
|
|
106
|
+
return !rule[:except].include?(action) if rule[:except]
|
|
107
|
+
|
|
108
|
+
true
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def register_throttle_hit(rule)
|
|
112
|
+
store = throttle_store!
|
|
113
|
+
now = Time.now.to_i
|
|
114
|
+
window = now / rule[:period]
|
|
115
|
+
reset_at = (window + 1) * rule[:period]
|
|
116
|
+
key = "throttleable:#{rule[:name]}:#{throttle_discriminator(rule)}:#{window}"
|
|
117
|
+
|
|
118
|
+
# Atomic increment-with-expiry. Some stores return nil on the first
|
|
119
|
+
# increment of a missing key — seed it to 1 in that case.
|
|
120
|
+
count = store.increment(key, 1, expires_in: rule[:period])
|
|
121
|
+
count ||= seed_throttle_key(store, key, rule[:period])
|
|
122
|
+
|
|
123
|
+
{ count: count.to_i, reset_at: reset_at, retry_after: [reset_at - now, 0].max }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def seed_throttle_key(store, key, period)
|
|
127
|
+
store.write(key, 1, expires_in: period) if store.respond_to?(:write)
|
|
128
|
+
1
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def throttle_discriminator(rule)
|
|
132
|
+
instance_exec(&rule[:by])
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def emit_throttle_headers(rule, result)
|
|
136
|
+
return unless respond_to?(:response) && response
|
|
137
|
+
|
|
138
|
+
remaining = [rule[:limit] - result[:count], 0].max
|
|
139
|
+
response.set_header("X-RateLimit-Limit", rule[:limit].to_s)
|
|
140
|
+
response.set_header("X-RateLimit-Remaining", remaining.to_s)
|
|
141
|
+
response.set_header("X-RateLimit-Reset", result[:reset_at].to_s)
|
|
142
|
+
response.set_header("Retry-After", result[:retry_after].to_s) if result[:count] > rule[:limit]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def throttle_store!
|
|
146
|
+
store = self.class.throttleable_store
|
|
147
|
+
return store if store
|
|
148
|
+
|
|
149
|
+
raise ArgumentError,
|
|
150
|
+
"ConcernsOnRails::Controllers::Throttleable: no store configured. " \
|
|
151
|
+
"Set `self.throttleable_store = Rails.cache` (must support atomic #increment)."
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def throttle_action_name
|
|
155
|
+
respond_to?(:action_name) ? action_name.to_s : nil
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
|
|
3
|
+
module ConcernsOnRails
|
|
4
|
+
module Controllers
|
|
5
|
+
# Per-request `Time.zone` selection from the request params, the `Time-Zone`
|
|
6
|
+
# header, and/or a cookie, wrapped in an `around_action` so `Time.zone` is set
|
|
7
|
+
# for the action and restored afterwards. The time analogue of Localizable
|
|
8
|
+
# (which does the same for `I18n.locale`). Dependency-free.
|
|
9
|
+
#
|
|
10
|
+
# class ApplicationController < ActionController::Base
|
|
11
|
+
# include ConcernsOnRails::Controllers::Timezoneable
|
|
12
|
+
#
|
|
13
|
+
# timezoneable available: ["UTC", "Eastern Time (US & Canada)"], default: "UTC"
|
|
14
|
+
# # timezoneable param: :tz, header: false, cookie: :time_zone
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# Resolution order: `params[param]` → `Time-Zone` header → cookie (if enabled)
|
|
18
|
+
# → `default` → the current `Time.zone`. Every value — the configured
|
|
19
|
+
# `available:`/`default:` AND each request candidate — is resolved through
|
|
20
|
+
# `ActiveSupport::TimeZone[...]`, so a zone accepted at boot can never be
|
|
21
|
+
# rejected at request time.
|
|
22
|
+
#
|
|
23
|
+
# Options: `available:` (allow-list applied to param/header/cookie matching;
|
|
24
|
+
# `default:` bypasses it, mirroring Localizable), `default:`, `param:`
|
|
25
|
+
# (default `:time_zone`), `header:` (default `true`), `cookie:` (default
|
|
26
|
+
# `false`; `true` reads the `:time_zone` cookie, or pass a cookie name).
|
|
27
|
+
module Timezoneable
|
|
28
|
+
extend ActiveSupport::Concern
|
|
29
|
+
|
|
30
|
+
included do
|
|
31
|
+
class_attribute :timezoneable_options, instance_accessor: false, default: {}
|
|
32
|
+
around_action :switch_time_zone
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
module ClassMethods
|
|
36
|
+
def timezoneable(available: nil, default: nil, param: :time_zone, header: true, cookie: false)
|
|
37
|
+
self.timezoneable_options = {
|
|
38
|
+
available: validate_time_zones(available),
|
|
39
|
+
default: validate_time_zone(default),
|
|
40
|
+
param: param&.to_sym,
|
|
41
|
+
header: header,
|
|
42
|
+
cookie: cookie == true ? :time_zone : cookie.presence
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def validate_time_zones(zones)
|
|
49
|
+
return nil if zones.nil?
|
|
50
|
+
|
|
51
|
+
Array(zones).map { |zone| validate_time_zone(zone) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Resolve a single configured zone to an ActiveSupport::TimeZone at boot,
|
|
55
|
+
# raising on an unknown name so misconfiguration fails fast.
|
|
56
|
+
def validate_time_zone(zone)
|
|
57
|
+
return nil if zone.nil?
|
|
58
|
+
|
|
59
|
+
ActiveSupport::TimeZone[zone] ||
|
|
60
|
+
raise(ArgumentError, "ConcernsOnRails::Controllers::Timezoneable: unknown time zone '#{zone}'")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Public so subclasses can override; runs the action under the resolved
|
|
65
|
+
# zone. UNGUARDED on purpose — it touches `Time` globally, not the response
|
|
66
|
+
# (mirrors Localizable#switch_locale).
|
|
67
|
+
def switch_time_zone(&)
|
|
68
|
+
Time.use_zone(resolved_time_zone, &)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The ActiveSupport::TimeZone chosen for this request — always one `Time`
|
|
72
|
+
# can switch to (falls back to the current `Time.zone`).
|
|
73
|
+
def resolved_time_zone
|
|
74
|
+
opts = self.class.timezoneable_options
|
|
75
|
+
allowed = opts[:available]
|
|
76
|
+
candidate = zone_from_param(opts, allowed) ||
|
|
77
|
+
zone_from_header(opts, allowed) ||
|
|
78
|
+
zone_from_cookie(opts, allowed) ||
|
|
79
|
+
opts[:default]
|
|
80
|
+
|
|
81
|
+
resolve_zone(candidate) || Time.zone
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# Match a raw source value against the allow-list (when present), returning
|
|
87
|
+
# the resolved TimeZone or nil so the resolution chain falls through.
|
|
88
|
+
def match_zone(raw, allowed)
|
|
89
|
+
return nil if raw.blank?
|
|
90
|
+
|
|
91
|
+
zone = ActiveSupport::TimeZone[raw.to_s]
|
|
92
|
+
return nil unless zone
|
|
93
|
+
return nil if allowed&.none? { |z| z.name == zone.name }
|
|
94
|
+
|
|
95
|
+
zone
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Final coercion: `default` is already a TimeZone; the Time.zone fallback is
|
|
99
|
+
# handled by the caller.
|
|
100
|
+
def resolve_zone(candidate)
|
|
101
|
+
return nil if candidate.blank?
|
|
102
|
+
return candidate if candidate.is_a?(ActiveSupport::TimeZone)
|
|
103
|
+
|
|
104
|
+
ActiveSupport::TimeZone[candidate.to_s]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def zone_from_param(opts, allowed)
|
|
108
|
+
return nil unless opts[:param] && respond_to?(:params) && params
|
|
109
|
+
|
|
110
|
+
match_zone(params[opts[:param]], allowed)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def zone_from_header(opts, allowed)
|
|
114
|
+
return nil unless opts[:header] && respond_to?(:request)
|
|
115
|
+
|
|
116
|
+
req = request
|
|
117
|
+
header = req.respond_to?(:headers) ? req.headers["Time-Zone"] : nil
|
|
118
|
+
match_zone(header, allowed)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def zone_from_cookie(opts, allowed)
|
|
122
|
+
key = opts[:cookie]
|
|
123
|
+
return nil unless key && respond_to?(:cookies) && cookies
|
|
124
|
+
|
|
125
|
+
match_zone(cookies[key], allowed)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
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,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
|
data/lib/concerns_on_rails.rb
CHANGED
|
@@ -13,6 +13,8 @@ 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
15
|
require "concerns_on_rails/support/html_sanitizers"
|
|
16
|
+
require "concerns_on_rails/support/masker"
|
|
17
|
+
require "concerns_on_rails/support/money"
|
|
16
18
|
|
|
17
19
|
# Model concerns
|
|
18
20
|
require "concerns_on_rails/models/sluggable"
|
|
@@ -31,6 +33,8 @@ require "concerns_on_rails/models/addressable"
|
|
|
31
33
|
require "concerns_on_rails/models/sequenceable"
|
|
32
34
|
require "concerns_on_rails/models/taggable"
|
|
33
35
|
require "concerns_on_rails/models/sanitizable"
|
|
36
|
+
require "concerns_on_rails/models/maskable"
|
|
37
|
+
require "concerns_on_rails/models/monetizable"
|
|
34
38
|
|
|
35
39
|
# Controller concerns
|
|
36
40
|
require "concerns_on_rails/controllers/paginatable"
|
|
@@ -40,6 +44,10 @@ require "concerns_on_rails/controllers/respondable"
|
|
|
40
44
|
require "concerns_on_rails/controllers/error_handleable"
|
|
41
45
|
require "concerns_on_rails/controllers/includable"
|
|
42
46
|
require "concerns_on_rails/controllers/secure_headable"
|
|
47
|
+
require "concerns_on_rails/controllers/localizable"
|
|
48
|
+
require "concerns_on_rails/controllers/authorizable"
|
|
49
|
+
require "concerns_on_rails/controllers/throttleable"
|
|
50
|
+
require "concerns_on_rails/controllers/timezoneable"
|
|
43
51
|
|
|
44
52
|
# Backwards compatibility (top-level aliases for pre-1.6 module paths)
|
|
45
53
|
require "concerns_on_rails/legacy_aliases"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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.14.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ethan Nguyen
|
|
@@ -70,18 +70,24 @@ files:
|
|
|
70
70
|
- CODE_OF_CONDUCT.md
|
|
71
71
|
- README.md
|
|
72
72
|
- lib/concerns_on_rails.rb
|
|
73
|
+
- lib/concerns_on_rails/controllers/authorizable.rb
|
|
73
74
|
- lib/concerns_on_rails/controllers/error_handleable.rb
|
|
74
75
|
- lib/concerns_on_rails/controllers/filterable.rb
|
|
75
76
|
- lib/concerns_on_rails/controllers/includable.rb
|
|
77
|
+
- lib/concerns_on_rails/controllers/localizable.rb
|
|
76
78
|
- lib/concerns_on_rails/controllers/paginatable.rb
|
|
77
79
|
- lib/concerns_on_rails/controllers/respondable.rb
|
|
78
80
|
- lib/concerns_on_rails/controllers/secure_headable.rb
|
|
79
81
|
- lib/concerns_on_rails/controllers/sortable.rb
|
|
82
|
+
- lib/concerns_on_rails/controllers/throttleable.rb
|
|
83
|
+
- lib/concerns_on_rails/controllers/timezoneable.rb
|
|
80
84
|
- lib/concerns_on_rails/legacy_aliases.rb
|
|
81
85
|
- lib/concerns_on_rails/models/activatable.rb
|
|
82
86
|
- lib/concerns_on_rails/models/addressable.rb
|
|
83
87
|
- lib/concerns_on_rails/models/expirable.rb
|
|
84
88
|
- lib/concerns_on_rails/models/hashable.rb
|
|
89
|
+
- lib/concerns_on_rails/models/maskable.rb
|
|
90
|
+
- lib/concerns_on_rails/models/monetizable.rb
|
|
85
91
|
- lib/concerns_on_rails/models/normalizable.rb
|
|
86
92
|
- lib/concerns_on_rails/models/publishable.rb
|
|
87
93
|
- lib/concerns_on_rails/models/sanitizable.rb
|
|
@@ -97,6 +103,8 @@ files:
|
|
|
97
103
|
- lib/concerns_on_rails/support/address_data.rb
|
|
98
104
|
- lib/concerns_on_rails/support/column_guard.rb
|
|
99
105
|
- lib/concerns_on_rails/support/html_sanitizers.rb
|
|
106
|
+
- lib/concerns_on_rails/support/masker.rb
|
|
107
|
+
- lib/concerns_on_rails/support/money.rb
|
|
100
108
|
- lib/concerns_on_rails/support/random_value.rb
|
|
101
109
|
- lib/concerns_on_rails/support/sequence_calculator.rb
|
|
102
110
|
- lib/concerns_on_rails/version.rb
|