moderate 0.1.0 → 1.0.0.beta1
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/.rubocop.yml +8 -0
- data/.simplecov +62 -0
- data/AGENTS.md +7 -0
- data/Appraisals +16 -0
- data/CHANGELOG.md +71 -1
- data/CLAUDE.md +7 -0
- data/README.md +376 -29
- data/Rakefile +28 -2
- data/app/controllers/concerns/moderate/moderation.rb +161 -0
- data/app/controllers/moderate/appeals_controller.rb +190 -0
- data/app/controllers/moderate/application_controller.rb +45 -0
- data/app/controllers/moderate/notices_controller.rb +382 -0
- data/app/controllers/moderate/transparency_reports_controller.rb +30 -0
- data/app/helpers/moderate/engine_helper.rb +151 -0
- data/app/views/moderate/appeals/new.html.erb +78 -0
- data/app/views/moderate/notices/new.html.erb +255 -0
- data/app/views/moderate/transparency_reports/_summary_card.html.erb +20 -0
- data/app/views/moderate/transparency_reports/show.html.erb +52 -0
- data/config/moderate/blocklists/en.yml +81 -0
- data/config/moderate/blocklists/es.yml +40 -0
- data/config/routes.rb +36 -0
- data/docs/compliance.md +178 -0
- data/docs/configuration.md +326 -0
- data/docs/dsa-notice-form.md +371 -0
- data/docs/madmin.md +490 -0
- data/docs/notifications.md +363 -0
- data/examples/aws_rekognition_adapter.rb +140 -0
- data/examples/openai_moderation_adapter.rb +111 -0
- data/gemfiles/rails_7.1.gemfile +36 -0
- data/gemfiles/rails_7.2.gemfile +36 -0
- data/gemfiles/rails_8.1.gemfile +36 -0
- data/lib/generators/moderate/install_generator.rb +56 -0
- data/lib/generators/moderate/templates/create_moderate_tables.rb.erb +237 -0
- data/lib/generators/moderate/templates/initializer.rb +198 -0
- data/lib/generators/moderate/views_generator.rb +63 -0
- data/lib/moderate/configuration.rb +341 -0
- data/lib/moderate/engine.rb +138 -0
- data/lib/moderate/errors.rb +26 -0
- data/lib/moderate/event.rb +75 -0
- data/lib/moderate/filters/base.rb +126 -0
- data/lib/moderate/filters/wordlist.rb +255 -0
- data/lib/moderate/jobs/classify_job.rb +158 -0
- data/lib/moderate/label.rb +111 -0
- data/lib/moderate/macros.rb +90 -0
- data/lib/moderate/models/appeal.rb +154 -0
- data/lib/moderate/models/application_record.rb +31 -0
- data/lib/moderate/models/block.rb +203 -0
- data/lib/moderate/models/concerns/actor.rb +174 -0
- data/lib/moderate/models/concerns/content_filterable.rb +155 -0
- data/lib/moderate/models/concerns/reportable.rb +282 -0
- data/lib/moderate/models/flag.rb +136 -0
- data/lib/moderate/models/report.rb +620 -0
- data/lib/moderate/result.rb +176 -0
- data/lib/moderate/services/intake_appeal.rb +89 -0
- data/lib/moderate/services/intake_notice.rb +132 -0
- data/lib/moderate/services/intake_report.rb +132 -0
- data/lib/moderate/services/resolve_appeal.rb +134 -0
- data/lib/moderate/services/resolve_flag.rb +101 -0
- data/lib/moderate/services/resolve_report.rb +291 -0
- data/lib/moderate/version.rb +1 -1
- data/lib/moderate.rb +365 -18
- data/log/development.log +0 -0
- data/log/test.log +0 -0
- metadata +154 -15
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# The DSA notice form — a mountable, X-style legal-notice intake
|
|
2
|
+
|
|
3
|
+
The EU **Digital Services Act, Article 16 ("Notice and action")** says every hosting service that serves EU users must offer a **public, electronic** way for *anyone* — not just logged-in users — to flag illegal content, and must **acknowledge receipt** of that notice. This is the form you see at the bottom of X, YouTube, Reddit: "Report illegal content (EU)". It is a hard requirement, it is separate from your in-app "Report" button, and it is exactly the kind of legally-loaded plumbing `moderate` exists to take off your plate.
|
|
4
|
+
|
|
5
|
+
So `moderate` ships it as a **mountable Rails engine**: one line in your routes and you have a public notice form with the DSA Art. 16 mechanics built in. The form, the controller, the model, the bot gate, the rate-limit, and the confirmation-of-receipt are all done for you. The default view is plain, accessible, and CSS-framework-agnostic — and it's **overridable the way Devise does it**: run one generator to eject the templates into your app and style them to match your brand.
|
|
6
|
+
|
|
7
|
+
It is also **completely optional**. If you'd rather build the public notice page yourself (you already have a design system, you want it inside an existing controller, whatever), don't mount the engine — use `Moderate::Report` (with `intake_kind: "dsa"`) directly and skip everything below. The engine is a convenience, not a dependency.
|
|
8
|
+
|
|
9
|
+
> [!NOTE]
|
|
10
|
+
> This is the **public, regulator-facing** form (DSA Art. 16). It is *not* the in-app "Report this comment" button (that's `current_user.report!(...)` from [the Actors section](../README.md#-actors-report--block)) and it is *not* the admin moderation queue (that's BYOUI — `moderate` gives you the primitives). Two intakes, one `moderate_reports` table, distinguished by `intake_kind`. See [why the models](../README.md#-why-the-models).
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## TL;DR
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
# config/routes.rb
|
|
18
|
+
# The engine's routes are RELATIVE — YOU choose the mount path. The gem hardcodes no
|
|
19
|
+
# prefix; pick whatever reads right for your app (/trust, /moderation, /dsa, …).
|
|
20
|
+
mount Moderate::Engine => "/trust" # => form at /trust/notices/new
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
# config/initializers/moderate.rb
|
|
25
|
+
Moderate.configure do |config|
|
|
26
|
+
config.notice_form_enabled = true # default; flip to false to hard-disable the engine
|
|
27
|
+
config.notice_rate_limit = { max: 5, within: 1.hour } # per-IP throttle
|
|
28
|
+
# Bot gate: install the rails_cloudflare_turnstile gem (below) and it auto-integrates —
|
|
29
|
+
# nothing to set here. Or wire any other check with config.notice_guard.
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
That's it — `GET /trust/notices/new` renders the form, `POST /trust/notices` validates + persists a `Moderate::Report` with `intake_kind: "dsa"`, fires the `notice_received` notification (your confirmation-of-receipt email + admin alert), and redirects back with a confirmation message. The durable, on-record proof of receipt (Art. 16(4)) is the report's `acknowledged_at` timestamp.
|
|
34
|
+
|
|
35
|
+
The form also **prefills and partially locks** itself from the request (see [Prefill & lock](#prefill--lock-art-162b-c)), and **auto-uses the `rails_cloudflare_turnstile` gem** as a bot gate when it's installed (see [The bot gate](#the-bot-gate-auto-integrates-rails_cloudflare_turnstile)) — both with zero extra wiring.
|
|
36
|
+
|
|
37
|
+
Want to restyle it? Eject the views, then edit them:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
rails generate moderate:views
|
|
41
|
+
# => creates app/views/moderate/notices/new.html.erb (and friends) in YOUR app
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Why an engine (and why like Devise)
|
|
47
|
+
|
|
48
|
+
Most of `moderate` is deliberately **UI-agnostic** — Trust & Safety lives in admin surfaces, and we don't presume to own your admin chrome. The DSA notice form is the **one exception**, for three reasons:
|
|
49
|
+
|
|
50
|
+
1. **It must exist and it must be public.** Unlike the admin queue (which you'd build anyway), the Art. 16 form is a legal must-have that has nothing to do with your product UI. Shipping it means most apps get the required intake mechanism with one line instead of researching the regulation.
|
|
51
|
+
2. **The fields are dictated by law, not by you.** The legal-reason taxonomy, the good-faith statement, the "exact URL" requirement, the EU member-state selector — these come straight from the DSA. There's no product decision to make, so there's nothing to design. We can ship a correct default.
|
|
52
|
+
3. **You still own the look.** A bundled-but-overridable view is the best of both: it works out of the box, and you can make it yours without forking the gem.
|
|
53
|
+
|
|
54
|
+
That third point is the **Devise pattern**, and we copy it on purpose because every Rails developer already understands it:
|
|
55
|
+
|
|
56
|
+
| Devise | `moderate` |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| `mount` is implicit via `devise_for` | `mount Moderate::Engine => "/<your-path>"` |
|
|
59
|
+
| Views ship inside the gem | Views ship inside the engine (`app/views/moderate/notices/`) |
|
|
60
|
+
| `rails g devise:views` copies them to your app | `rails g moderate:views` copies them to your app |
|
|
61
|
+
| `config.parent_controller` | `config.parent_controller` |
|
|
62
|
+
| Rails view lookup prefers `app/views` over the gem | identical — an ejected view **shadows** the bundled one, zero config |
|
|
63
|
+
| Works untouched if you never eject | Works untouched if you never eject |
|
|
64
|
+
|
|
65
|
+
The magic in both is the same boring Rails fact: **the host app's `app/views` sits ahead of any engine's view paths in the lookup chain.** So when you run `moderate:views` and a file appears at `app/views/moderate/notices/new.html.erb`, Rails renders *yours* instead of the gem's — no registration, no config flag, no monkey-patch. Delete your copy and the gem's default comes back.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## How it mounts
|
|
70
|
+
|
|
71
|
+
`Moderate::Engine` is an **isolated** engine (`isolate_namespace Moderate`), so its routes, controllers, helpers, and table prefixes never collide with your app. Its routes are declared **relative** — the engine only owns `resources :notices` (and a root that redirects to the form) — so **you choose the mount path**; the gem hardcodes nothing:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
# config/routes.rb
|
|
75
|
+
Rails.application.routes.draw do
|
|
76
|
+
mount Moderate::Engine => "/trust" # form at /trust/notices/new
|
|
77
|
+
# ...your app routes
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Pick whatever path reads right for your app — there is no special "/legal" prefix baked in:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
mount Moderate::Engine => "/trust" # → /trust/notices/new
|
|
85
|
+
mount Moderate::Engine => "/moderation" # → /moderation/notices/new
|
|
86
|
+
mount Moderate::Engine => "/dsa" # → /dsa/notices/new
|
|
87
|
+
mount Moderate::Engine => "/legal" # → /legal/notices/new (also fine — your call)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The engine's routes (in the gem, you never write these):
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
# config/routes.rb inside the engine
|
|
94
|
+
Moderate::Engine.routes.draw do
|
|
95
|
+
resources :notices, only: %i[new create]
|
|
96
|
+
root to: "notices#new"
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- `GET <mount>/notices/new` — the form
|
|
101
|
+
- `POST <mount>/notices` — submit (validate + persist + confirm receipt); on success it redirects back to the form with a confirmation flash
|
|
102
|
+
- `GET <mount>` — the engine root redirects to the form
|
|
103
|
+
|
|
104
|
+
There is no per-notice `show`/receipt page: a notice is a `Moderate::Report` with no public, enumerable identifier, so we never expose one over a guessable URL. The confirmation of receipt is delivered out-of-band through the `notice_received` notify hook (your email), and the durable proof is the report's `acknowledged_at` timestamp.
|
|
105
|
+
|
|
106
|
+
Link to it from your footer using the engine's named routes (mounted engines expose a helper named after the mount, here `moderate`):
|
|
107
|
+
|
|
108
|
+
```erb
|
|
109
|
+
<%= link_to "Report illegal content (EU)", moderate.new_notice_path %>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
> [!TIP]
|
|
113
|
+
> Want the canonical "DSA point of contact" page the regulation also asks for (Art. 11/12)? The engine root is a fine place to host a short page that links to the form and lists your contact address — but that's content, not code, so we leave the copy to you. Eject the views and edit `new.html.erb`'s intro block.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Prefill & lock (Art. 16(2)(b)/(c))
|
|
118
|
+
|
|
119
|
+
The form **prefills itself from the request**, X-style, so a notifier doesn't have to copy-paste what they're flagging — and it **locks the fields it shouldn't let them edit**.
|
|
120
|
+
|
|
121
|
+
### Reported-content prefill (from the query string, stays editable)
|
|
122
|
+
|
|
123
|
+
Deep-link to the form from any piece of content and pass the details in the query string. The param names are the gem's documented contract:
|
|
124
|
+
|
|
125
|
+
| Query param | Prefills | Maps to (Report column) | DSA |
|
|
126
|
+
| --- | --- | --- | --- |
|
|
127
|
+
| `content_url` | the exact URL field | `subject_url` | Art. 16(2)(b) — "the exact electronic location" |
|
|
128
|
+
| `content_type` | the content-type select | `content_type` | the host-agnostic bucket for the snapshot/queue |
|
|
129
|
+
| `content_author` | the account/handle field | `reported_account_identifier` | optional host-side identity of the content |
|
|
130
|
+
| `content_id` | the account/handle field (fallback if no `content_author`) | `reported_account_identifier` | optional host-side identifier |
|
|
131
|
+
|
|
132
|
+
```erb
|
|
133
|
+
<%= link_to "Report this (EU notice)",
|
|
134
|
+
moderate.new_notice_path(
|
|
135
|
+
content_url: request.original_url,
|
|
136
|
+
content_type: "message",
|
|
137
|
+
content_author: @author.username
|
|
138
|
+
) %>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
These are the **reported-content** fields, so they stay fully **editable** — the notifier may correct the URL, change the content type, etc. `content_type` is only echoed when the query value is one the model would actually accept (a crafted `?content_type=<script>` is ignored), so a query param can't pre-poison the select.
|
|
142
|
+
|
|
143
|
+
### Identity prefill + lock (from Devise `current_user`, locked)
|
|
144
|
+
|
|
145
|
+
The form is public and anonymous-friendly, but when someone **is** logged in we prefill their **name/email** from `current_user` (detected safely — the gem never hard-depends on Devise; it checks `respond_to?(:current_user)`). Those **identity** fields are then rendered **readonly (locked)** so a logged-in notifier can't put someone else's name/email on a legal notice — and the controller **re-asserts identity server-side** on submit, so even a tampered request that re-enables the field can't spoof it. For an anonymous notifier (no `current_user`) the name/email fields are blank and editable — they're the only identity there is.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## The controller / model boundary
|
|
150
|
+
|
|
151
|
+
We keep the split clean and obvious — the controller does HTTP, the model does Trust & Safety.
|
|
152
|
+
|
|
153
|
+
### `Moderate::Report` (intake_kind: "dsa") — the model (does the real work)
|
|
154
|
+
|
|
155
|
+
A notice is **not a fourth table**. It's a `Moderate::Report` distinguished by `intake_kind: "dsa"` — the same table that backs in-app reports. This is on purpose: a notice and a report share the same decision workflow, the same evidence snapshot, the same appeal window, the same transparency counters. One queue, one statement-of-reasons path, one Art. 24 aggregation — whether the flag came from a logged-in user tapping "Report" or an anonymous lawyer filling in the public form. The `Moderate::Services::IntakeNotice` service forces that DSA shape and runs the shared intake (save + acknowledge + audit + the `notice_received` event).
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
# Conceptually (the real model/service live in the gem; this is the contract you rely on):
|
|
159
|
+
intake = Moderate::Services::IntakeNotice.new(
|
|
160
|
+
attributes: {
|
|
161
|
+
legal_reason: "intellectual_property", # from the DSA taxonomy (see below)
|
|
162
|
+
legal_country_code: "ES", # ISO-3166 EU/EEA selector
|
|
163
|
+
content_type: "message", # host-agnostic CONTENT_TYPES bucket
|
|
164
|
+
subject_url: "https://yourapp.com/p/123", # Art. 16(2)(b) exact location
|
|
165
|
+
message: "This post reproduces my copyrighted photo without licence.",
|
|
166
|
+
notifier_name: "Jane Doe",
|
|
167
|
+
notifier_email: "jane@example.com",
|
|
168
|
+
good_faith_confirmed: "1" # Art. 16(2)(d), must be checked
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
intake.save # → persisted as a moderate_reports row, intake_kind: "dsa", status: "open"
|
|
172
|
+
intake.report.acknowledged_at # → set; the durable Art. 16(4) proof of receipt
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The model owns: validations (every required DSA field, a real email, an `http(s)`-URL check, the good-faith attestation being true, the narrow `protection_of_minors` anonymity carve-out), the immutable evidence snapshot (it tries to resolve `subject_url` to a reportable record and snapshot it, so evidence survives edits/deletes), and dropping into `Moderate::Report.pending` so your admins act on it exactly like any other report. The service fires the `notice_received` event through `config.notify` — that's your confirmation-of-receipt to the notifier **and** your admin alert, from one hook.
|
|
176
|
+
|
|
177
|
+
> [!NOTE]
|
|
178
|
+
> "Confirmation of receipt without undue delay" (Art. 16(4)) is satisfied two ways: the durable record is `acknowledged_at` (a database fact, set before any email is attempted), and the human-facing confirmation is the `notice_received` event → your mailer. The gem emits the event; you wire it to [`goodmail`](https://github.com/rameerez/goodmail) (or any mailer) once, the same way you wire `report_received`. See [Notifications](../README.md#-notifications---audit--one-hook-each).
|
|
179
|
+
|
|
180
|
+
### `Moderate::NoticesController` — the controller (does HTTP only)
|
|
181
|
+
|
|
182
|
+
The controller is intentionally boring. It builds a prefilled (and partially locked) `Moderate::Report` for `new`, strong-params it on `create`, runs the **bot gate** and the **rate-limit** as `before_action`s, and on success redirects back to the form with a confirmation flash. On failure it re-renders `new` with `422` and the model's validation errors — standard Rails.
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
# Conceptually (lives in the gem):
|
|
186
|
+
module Moderate
|
|
187
|
+
class NoticesController < Moderate::ApplicationController
|
|
188
|
+
before_action :enforce_notice_enabled!
|
|
189
|
+
before_action :throttle_notices!, only: :create # config.notice_rate_limit
|
|
190
|
+
before_action :verify_human!, only: :create # auto-Turnstile, else config.notice_guard
|
|
191
|
+
|
|
192
|
+
def new
|
|
193
|
+
@report = Moderate::Report.new(prefill_attributes) # query-param + current_user prefill
|
|
194
|
+
@identity_locked = identity_locked? # lock name/email when signed in
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def create
|
|
198
|
+
intake = Moderate::Services::IntakeNotice.new(attributes: notice_params, reporter: current_notifier)
|
|
199
|
+
if intake.save
|
|
200
|
+
redirect_to new_notice_path, notice: t("moderate.notices.received"), status: :see_other
|
|
201
|
+
else
|
|
202
|
+
@report = intake.report
|
|
203
|
+
render :new, status: :unprocessable_entity
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`Moderate::ApplicationController` (the engine's base) inherits from `config.parent_controller.constantize` (default `"::ActionController::Base"` so it works even on API-only apps, with `protect_from_forgery` applied when available) — exactly the `parent_controller` indirection Devise uses, so you can point it at your own base controller to inherit your layout, locale-setting, and `current_user`.
|
|
211
|
+
|
|
212
|
+
#### The bot gate (auto-integrates `rails_cloudflare_turnstile`)
|
|
213
|
+
|
|
214
|
+
A public, unauthenticated form is a spam magnet. `moderate` ships a **single, request-time bot gate** (`verify_human!`) that auto-adapts to your bundle — with **zero wiring**:
|
|
215
|
+
|
|
216
|
+
- **If the [`rails_cloudflare_turnstile`](https://github.com/instrumentl/rails-cloudflare-turnstile) gem is installed**, the gate uses it automatically. That gem mixes its helpers in for you, so:
|
|
217
|
+
- the **view renders the widget** (`cloudflare_turnstile` + `cloudflare_turnstile_script_tag`), and
|
|
218
|
+
- the **controller verifies the challenge server-side** (`validate_cloudflare_turnstile`); a failed challenge (the gem's `RailsCloudflareTurnstile::Forbidden`) is turned into a friendly `422` so the submitter can retry.
|
|
219
|
+
|
|
220
|
+
You add the gem and its keys (its own `config/initializers/cloudflare_turnstile.rb`) — `moderate` needs **no env var and no config** to pick it up. Detection is via `defined?`/`respond_to?`, and `rails_cloudflare_turnstile` is **not** a dependency of `moderate`.
|
|
221
|
+
- **Otherwise**, the gate falls back to a configurable proc, `config.notice_guard` (no-op by default, so the form just works in dev/test and for apps that gate at the edge). The proc receives the controller and returns a boolean:
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
# Use hCaptcha / reCAPTCHA / your own check instead of Turnstile:
|
|
225
|
+
config.notice_guard = ->(controller) { MyCaptcha.verify(controller.params["my-token"]) }
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
An exception in the guard is treated as "failed closed" (re-render the form so the submitter retries) — a flaky bot service must never 500 a legal notice form.
|
|
229
|
+
|
|
230
|
+
We default to recommending Turnstile (privacy-friendly, free, the RailsFast house default), but you're never locked in: the guard is just a lambda.
|
|
231
|
+
|
|
232
|
+
Some clients cannot render a browser challenge at all (for example a native shell
|
|
233
|
+
posting through your authenticated web session, or an edge layer that has already
|
|
234
|
+
verified the request). For those cases, keep the browser gate on for normal traffic
|
|
235
|
+
and skip it per request:
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
config.notice_human_verification_skip_if = ->(controller) {
|
|
239
|
+
controller.request.user_agent.to_s.match?(/Hotwire Native/i)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
config.appeal_human_verification_skip_if = ->(controller) {
|
|
243
|
+
controller.request.user_agent.to_s.match?(/Hotwire Native/i)
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
The proc receives the controller and defaults to `nil` (never skip). If it raises,
|
|
248
|
+
the gem treats that as "do not skip" so a broken predicate cannot accidentally turn
|
|
249
|
+
off the public form's bot gate.
|
|
250
|
+
|
|
251
|
+
#### The rate-limit hook
|
|
252
|
+
|
|
253
|
+
On Rails 7.2+ you could use the built-in `rate_limit` API; `moderate` instead implements a tiny per-IP, cache-backed counter (`Rails.cache`) as a `before_action`, so it honors your **runtime** `config.notice_rate_limit` (the class-level macro is evaluated at class load, before your initializer has run). Configure it once:
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
config.notice_rate_limit = { max: 5, within: 1.hour } # default
|
|
257
|
+
config.notice_rate_limit = false # disable (you throttle at the edge)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
When tripped, `create` responds `429 Too Many Requests` with a retry message, rendered through the same (overridable) view. Both gates are deliberately **defense in depth** and both degrade to "off" gracefully, so the form never becomes a support burden in environments where you don't need them.
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## The form fields (the DSA Art. 16 contract)
|
|
265
|
+
|
|
266
|
+
These are the fields the regulation requires, mirrored on the X / YouTube public forms. The default view renders exactly this set; if you eject and customize, **keep all of them** — they're what makes the notice legally valid (and they map 1:1 to the model's validations).
|
|
267
|
+
|
|
268
|
+
| Field | Param (`notice[...]`) | Required | Notes |
|
|
269
|
+
| --- | --- | --- | --- |
|
|
270
|
+
| **Legal reason** | `legal_reason` | yes | A `<select>` from the **DSA statement-of-reasons taxonomy** (`Moderate::Report::DSA_LEGAL_REASONS`). This is the regulator-aligned set, *not* your in-app community-report categories. |
|
|
271
|
+
| **Exact URL** | `subject_url` | yes | "the exact electronic location of that information" — Art. 16(2)(b). Validated as an `http(s)` URL; the model tries to resolve it to a reportable record for the evidence snapshot. Prefillable via `?content_url=`. |
|
|
272
|
+
| **Content type** | `content_type` | yes | A `<select>` from the host-agnostic `Moderate::Report::CONTENT_TYPES` bucket — keeps the snapshot/queue tidy. Prefillable via `?content_type=`. |
|
|
273
|
+
| **Account/handle** | `reported_account_identifier` | no | Optional host-side identity of the content (a username). Prefillable via `?content_author=` / `?content_id=`. |
|
|
274
|
+
| **Explanation** | `message` | yes | The "sufficiently substantiated explanation … why the individual or entity alleges the information to be illegal" — Art. 16(2)(a). Free text. |
|
|
275
|
+
| **Your name** | `notifier_name` | yes* | Art. 16(2)(c). *Optional only for `protection_of_minors` notices, where the DSA permits anonymity. Prefilled + **locked** from `current_user` when signed in. |
|
|
276
|
+
| **Your email** | `notifier_email` | yes | Art. 16(2)(c) — where the confirmation of receipt and the decision go. Validated as a real address. Prefilled + **locked** from `current_user` when signed in. |
|
|
277
|
+
| **EU member state** | `legal_country_code` | yes | ISO-3166 `<select>` of EU/EEA states (`Moderate::Report::EU_COUNTRY_CODES`) — establishes jurisdiction and routing. |
|
|
278
|
+
| **Good-faith statement** | `good_faith_confirmed` | yes | A checkbox attesting "the information and allegations are accurate and complete" — Art. 16(2)(d). Must be checked; the model rejects the save otherwise. |
|
|
279
|
+
|
|
280
|
+
The legal-reason `<select>` is driven by a single constant so the taxonomy stays consistent across the form, the model, and the Art. 17 statement-of-reasons and Art. 24 transparency counters:
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
Moderate::Report::DSA_LEGAL_REASONS
|
|
284
|
+
# => ["animal_welfare", "consumer_information", "cyber_violence", "data_protection_privacy",
|
|
285
|
+
# "illegal_or_harmful_speech", "civic_elections", "non_consensual_behavior",
|
|
286
|
+
# "pornography_sexualized_content", "protection_of_minors", "public_security",
|
|
287
|
+
# "scams_fraud", "scope_of_platform_service", "self_harm", "unsafe_illegal_products",
|
|
288
|
+
# "violence", "intellectual_property", "other"] # the EU Transparency Database vocabulary
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
> [!NOTE]
|
|
292
|
+
> **Two taxonomies, on purpose.** The in-app **community report** categories (`:harassment`, `:spam`, …) are about *your* rules; the **DSA legal-reason** taxonomy here is about *the law*. `moderate` ships both and never conflates them — a public notice always carries a `legal_reason`, an in-app report always carries a community `category`. See [DSA & compliance](../README.md#️-dsa--app-store-compliance-out-of-the-box).
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
## The default view, and how to override it (the `moderate:views` generator)
|
|
297
|
+
|
|
298
|
+
### Out of the box
|
|
299
|
+
|
|
300
|
+
The gem ships the templates inside the engine, under `app/views/moderate/`. They render with no CSS framework assumed, themable via CSS custom properties (`:root { --moderate-* }`), and pull every label/hint through `I18n` (`moderate.notices.*`) so you can translate without touching markup. The layout inherits nothing from your app by default; point `config.parent_controller` at your own base controller (and give it a `layout`) if you'd rather the forms sit inside your site chrome.
|
|
301
|
+
|
|
302
|
+
### Ejecting the views
|
|
303
|
+
|
|
304
|
+
When you want full control of the markup, run the generator — **the Devise move**:
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
rails generate moderate:views
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
That copies the engine's templates into your app. Because your `app/views` outranks the engine in Rails' view lookup, your copies **shadow** the gem's automatically — no config, no registration. Delete a file and the gem's default for that template comes back. Upgrade the gem and your ejected copies are untouched (you re-run the generator only if you *want* the new defaults).
|
|
311
|
+
|
|
312
|
+
> [!TIP]
|
|
313
|
+
> Generator naming follows the ecosystem: `moderate:install` (migration + initializer, like every other gem) and `moderate:views` (eject the form, like Devise). Nothing else is generated — we do **not** ship admin-view generators, because admin is BYOUI.
|
|
314
|
+
|
|
315
|
+
---
|
|
316
|
+
|
|
317
|
+
## Staying optional: ignore the engine entirely
|
|
318
|
+
|
|
319
|
+
The engine is a courtesy, not a contract. If you want to build the public notice page yourself — your own route, your own controller, your own styling — **don't mount it**, and talk to the service/model directly:
|
|
320
|
+
|
|
321
|
+
```ruby
|
|
322
|
+
class LegalController < ApplicationController
|
|
323
|
+
def new_notice = (@report = Moderate::Report.new)
|
|
324
|
+
|
|
325
|
+
def create_notice
|
|
326
|
+
intake = Moderate::Services::IntakeNotice.new(attributes: notice_params, reporter: current_user)
|
|
327
|
+
if intake.save
|
|
328
|
+
# your own confirmation page / mailer
|
|
329
|
+
else
|
|
330
|
+
@report = intake.report
|
|
331
|
+
render :new_notice, status: :unprocessable_entity
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
You still get every Art. 16 validation, the evidence snapshot, the durable `acknowledged_at`, the `notice_received` event, and the row landing in `Moderate::Report.pending` — you just bring the HTML. Mounting the engine is the fast path; using the service directly is the full-control path. Either way the compliance lives in the model, not the view.
|
|
338
|
+
|
|
339
|
+
And if you don't serve EU users at all? Skip both. Reporting, blocking, and filtering work standalone without ever touching the notice intake.
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## Configuration reference (notice form)
|
|
344
|
+
|
|
345
|
+
```ruby
|
|
346
|
+
Moderate.configure do |config|
|
|
347
|
+
config.notice_form_enabled = true # mount-able engine on/off (default: true)
|
|
348
|
+
config.parent_controller = "::ActionController::Base" # like Devise's config.parent_controller
|
|
349
|
+
config.appeal_form_enabled = true
|
|
350
|
+
config.appeal_rate_limit = { max: 10, within: 1.minute }
|
|
351
|
+
config.appeal_guard = ->(controller) { true }
|
|
352
|
+
config.appeal_human_verification_skip_if = ->(controller) { false }
|
|
353
|
+
config.appeal_return_path = "/"
|
|
354
|
+
config.notice_rate_limit = { max: 5, within: 1.hour } # per-IP throttle, or false to disable
|
|
355
|
+
|
|
356
|
+
# Bot gate:
|
|
357
|
+
# - Install `rails_cloudflare_turnstile` and it AUTO-integrates (widget + verify), no config here.
|
|
358
|
+
# - Otherwise set a guard proc (no-op by default) to use hCaptcha / reCAPTCHA / your own check:
|
|
359
|
+
config.notice_guard = ->(controller) { true } # ->(controller) { boolean }
|
|
360
|
+
config.notice_human_verification_skip_if = ->(controller) { false }
|
|
361
|
+
end
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Every one of these has a sensible default, so `mount Moderate::Engine => "/<your-path>"` with an otherwise-empty config gives you a working public notice form with the gem-owned Art. 16 mechanics. See the [main configuration reference](../README.md#configuration-reference) for the rest of `moderate`.
|
|
365
|
+
|
|
366
|
+
## See also
|
|
367
|
+
|
|
368
|
+
- [DSA & app-store compliance, out of the box](../README.md#️-dsa--app-store-compliance-out-of-the-box) — the full Art. 16/17/20/24 mapping
|
|
369
|
+
- [Notifications & audit](../README.md#-notifications---audit--one-hook-each) — wiring the `notice_received` confirmation-of-receipt
|
|
370
|
+
- [`docs/compliance.md`](compliance.md) — the App Store / Play / DSA checklist
|
|
371
|
+
- [Why the models](../README.md#-why-the-models) — why a notice and a report share one table
|