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,255 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
The default, OVERRIDABLE public DSA Art. 16 "notice and action" form.
|
|
3
|
+
|
|
4
|
+
This is the X / YouTube / Reddit-style "Report illegal content (EU)" page. It is
|
|
5
|
+
deliberately plain: no CSS framework is assumed, every label/hint is pulled
|
|
6
|
+
through I18n (`moderate.notices.*`), and it themes via CSS custom properties
|
|
7
|
+
(`:root { --moderate-* }`) so a host can restyle it without forking. To take full
|
|
8
|
+
control of the markup, eject it with `rails generate moderate:views` — your copy
|
|
9
|
+
at app/views/moderate/notices/new.html.erb then SHADOWS this one automatically
|
|
10
|
+
(Rails' view lookup puts the host's app/views ahead of the engine's).
|
|
11
|
+
|
|
12
|
+
The form binds a `Moderate::Report` (a notice is NOT a fourth table — it's a
|
|
13
|
+
Report with intake_kind "dsa") under the `:notice` scope, so params arrive as
|
|
14
|
+
params[:notice][...] for the controller's strong-params. KEEP ALL THE FIELDS if
|
|
15
|
+
you customize: they are dictated by DSA Article 16, not by product taste, and they
|
|
16
|
+
map 1:1 to the model's validations — dropping one makes the notice legally
|
|
17
|
+
invalid. https://eur-lex.europa.eu/eli/reg/2022/2065/oj (Art. 16)
|
|
18
|
+
|
|
19
|
+
PREFILL + LOCK (Art. 16(2)(b)/(c)):
|
|
20
|
+
* The REPORTED-CONTENT fields (subject URL, reason, details, content type) are
|
|
21
|
+
prefilled from the query string when the host deep-links here, and stay
|
|
22
|
+
EDITABLE — the notifier may correct them.
|
|
23
|
+
* The IDENTITY fields (name/email) are prefilled from Devise `current_user` when
|
|
24
|
+
someone is logged in, and are LOCKED (readonly) so a notice can't carry a
|
|
25
|
+
spoofed identity. The controller also re-asserts them server-side on submit.
|
|
26
|
+
%>
|
|
27
|
+
<% legal_reasons = Moderate::Report::DSA_LEGAL_REASONS %>
|
|
28
|
+
<% member_states = Moderate::Report::EU_COUNTRY_CODES %>
|
|
29
|
+
<% content_types = Moderate::Report::CONTENT_TYPES %>
|
|
30
|
+
<%#
|
|
31
|
+
Identity is "locked" (readonly) whenever it was prefilled from the signed-in user.
|
|
32
|
+
The controller computes this and hands it over as `@identity_locked`, so the view
|
|
33
|
+
never reaches for `current_user` itself (it isn't exposed as a view helper on the
|
|
34
|
+
engine's base controller). The controller ALSO re-derives identity server-side on
|
|
35
|
+
submit, so even a tampered request that re-enables a locked field can't spoof a
|
|
36
|
+
name/email onto a legal notice. An anonymous notice (no current_user) leaves these
|
|
37
|
+
fields fully editable.
|
|
38
|
+
%>
|
|
39
|
+
<% identity_locked = @identity_locked %>
|
|
40
|
+
|
|
41
|
+
<style>
|
|
42
|
+
/* Framework-agnostic defaults, all overridable via CSS custom properties so a
|
|
43
|
+
host can match brand colors without touching this markup. */
|
|
44
|
+
.moderate-notice {
|
|
45
|
+
--moderate-max-width: 40rem;
|
|
46
|
+
--moderate-accent: #111;
|
|
47
|
+
--moderate-border: #d4d4d4;
|
|
48
|
+
--moderate-muted: #666;
|
|
49
|
+
--moderate-radius: 8px;
|
|
50
|
+
max-width: var(--moderate-max-width);
|
|
51
|
+
margin: 2rem auto;
|
|
52
|
+
padding: 0 1rem;
|
|
53
|
+
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
54
|
+
line-height: 1.5;
|
|
55
|
+
color: #111;
|
|
56
|
+
}
|
|
57
|
+
.moderate-notice h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
|
|
58
|
+
.moderate-notice .moderate-intro { color: var(--moderate-muted); margin-bottom: 1.5rem; }
|
|
59
|
+
.moderate-notice .moderate-field { margin-bottom: 1.25rem; }
|
|
60
|
+
.moderate-notice label { display: block; font-weight: 600; margin-bottom: 0.35rem; }
|
|
61
|
+
.moderate-notice .moderate-hint { display: block; font-weight: 400; color: var(--moderate-muted); font-size: 0.85rem; margin-top: 0.25rem; }
|
|
62
|
+
.moderate-notice input[type="text"],
|
|
63
|
+
.moderate-notice input[type="url"],
|
|
64
|
+
.moderate-notice input[type="email"],
|
|
65
|
+
.moderate-notice select,
|
|
66
|
+
.moderate-notice textarea {
|
|
67
|
+
width: 100%;
|
|
68
|
+
padding: 0.6rem 0.7rem;
|
|
69
|
+
border: 1px solid var(--moderate-border);
|
|
70
|
+
border-radius: var(--moderate-radius);
|
|
71
|
+
font: inherit;
|
|
72
|
+
box-sizing: border-box;
|
|
73
|
+
}
|
|
74
|
+
.moderate-notice input[readonly] { background: #f4f4f4; color: var(--moderate-muted); cursor: not-allowed; }
|
|
75
|
+
.moderate-notice .moderate-checkbox { display: flex; gap: 0.5rem; align-items: flex-start; }
|
|
76
|
+
.moderate-notice .moderate-checkbox input { margin-top: 0.25rem; }
|
|
77
|
+
.moderate-notice .moderate-checkbox label { font-weight: 400; }
|
|
78
|
+
.moderate-notice button[type="submit"] {
|
|
79
|
+
background: var(--moderate-accent);
|
|
80
|
+
color: #fff;
|
|
81
|
+
border: 0;
|
|
82
|
+
border-radius: var(--moderate-radius);
|
|
83
|
+
padding: 0.7rem 1.25rem;
|
|
84
|
+
font: inherit;
|
|
85
|
+
font-weight: 600;
|
|
86
|
+
cursor: pointer;
|
|
87
|
+
}
|
|
88
|
+
.moderate-notice .moderate-errors {
|
|
89
|
+
border: 1px solid #e0a0a0;
|
|
90
|
+
background: #fbeaea;
|
|
91
|
+
color: #8a1f1f;
|
|
92
|
+
border-radius: var(--moderate-radius);
|
|
93
|
+
padding: 0.75rem 1rem;
|
|
94
|
+
margin-bottom: 1.25rem;
|
|
95
|
+
}
|
|
96
|
+
</style>
|
|
97
|
+
|
|
98
|
+
<div class="moderate-notice">
|
|
99
|
+
<h1><%= t("moderate.notices.title", default: "Report illegal content (EU)") %></h1>
|
|
100
|
+
<p class="moderate-intro">
|
|
101
|
+
<%= t("moderate.notices.intro",
|
|
102
|
+
default: "Use this form to notify us of content you believe is illegal under EU law (Digital Services Act, Article 16). Anyone can submit a notice — you do not need an account. We confirm receipt by email.") %>
|
|
103
|
+
</p>
|
|
104
|
+
|
|
105
|
+
<% if flash[:alert].present? %>
|
|
106
|
+
<div class="moderate-errors" role="alert">
|
|
107
|
+
<%= flash[:alert] %>
|
|
108
|
+
</div>
|
|
109
|
+
<% elsif flash[:notice].present? %>
|
|
110
|
+
<div class="moderate-errors" role="status">
|
|
111
|
+
<%= flash[:notice] %>
|
|
112
|
+
</div>
|
|
113
|
+
<% end %>
|
|
114
|
+
|
|
115
|
+
<%# Surface validation errors at the top so the submitter sees why a save failed. %>
|
|
116
|
+
<% if @report.respond_to?(:errors) && @report.errors.any? %>
|
|
117
|
+
<div class="moderate-errors" role="alert">
|
|
118
|
+
<%= @report.errors.full_messages.to_sentence %>
|
|
119
|
+
</div>
|
|
120
|
+
<% end %>
|
|
121
|
+
|
|
122
|
+
<%#
|
|
123
|
+
`scope: :notice` makes the params arrive as params[:notice][...], matching the
|
|
124
|
+
controller's `params.require(:notice)`. `url: notices_path` is the engine's
|
|
125
|
+
POST route, resolved within the mounted engine (e.g. /trust/notices).
|
|
126
|
+
%>
|
|
127
|
+
<%= form_with model: @report, scope: :notice, url: notices_path, method: :post do |form| %>
|
|
128
|
+
|
|
129
|
+
<%# Legal reason — the DSA statement-of-reasons taxonomy (Art. 16/17 ground). Editable. %>
|
|
130
|
+
<div class="moderate-field">
|
|
131
|
+
<%= form.label :legal_reason, t("moderate.notices.fields.legal_reason", default: "Legal reason") %>
|
|
132
|
+
<%= form.select :legal_reason,
|
|
133
|
+
legal_reasons.map { |reason| [t("moderate.legal_reasons.#{reason}", default: reason.to_s.tr("_", " ").capitalize), reason] },
|
|
134
|
+
{ prompt: t("moderate.notices.prompts.legal_reason", default: "Select a reason") },
|
|
135
|
+
required: true %>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<%# Exact URLs — "the exact electronic location of that information", Art. 16(2)(b).
|
|
139
|
+
Prefilled from ?content_url=… (X-style deep link) but EDITABLE: the notifier
|
|
140
|
+
may correct the link to the specific content they are reporting. One URL per
|
|
141
|
+
line; the model normalizes into subject_urls and keeps subject_url as first. %>
|
|
142
|
+
<div class="moderate-field">
|
|
143
|
+
<%= form.label :subject_urls, t("moderate.notices.fields.content_url", default: "Exact URL of the content") %>
|
|
144
|
+
<textarea name="notice[subject_urls]" id="notice_subject_urls" rows="3" required="required" placeholder="https://"><%= @report.subject_url_list.join("\n") %></textarea>
|
|
145
|
+
<span class="moderate-hint">
|
|
146
|
+
<%= t("moderate.notices.hints.content_url", default: "The exact link to the specific content you are reporting.") %>
|
|
147
|
+
</span>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<%# Content type — the host-agnostic bucket the snapshot/queue use. Prefilled from
|
|
151
|
+
?content_type=… when it names a valid bucket; editable. %>
|
|
152
|
+
<div class="moderate-field">
|
|
153
|
+
<%= form.label :content_type, t("moderate.notices.fields.content_type", default: "Type of content") %>
|
|
154
|
+
<%= form.select :content_type,
|
|
155
|
+
content_types.map { |type| [t("moderate.content_types.#{type}", default: type.to_s.tr("_", " ").capitalize), type] },
|
|
156
|
+
{ prompt: t("moderate.notices.prompts.content_type", default: "Select a content type") },
|
|
157
|
+
required: true %>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<%# Optional host-side handle/identifier the notice is about (a username, an
|
|
161
|
+
account id, …). Prefilled from ?content_author= or ?content_id= when the host
|
|
162
|
+
deep-links here; always EDITABLE and optional. %>
|
|
163
|
+
<div class="moderate-field">
|
|
164
|
+
<%= form.label :reported_account_identifier, t("moderate.notices.fields.reported_account_identifier", default: "Account or handle (optional)") %>
|
|
165
|
+
<%= form.text_field :reported_account_identifier %>
|
|
166
|
+
<span class="moderate-hint">
|
|
167
|
+
<%= t("moderate.notices.hints.reported_account_identifier", default: "If the content belongs to a specific account, add its username or handle.") %>
|
|
168
|
+
</span>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<%# Explanation — the "sufficiently substantiated explanation", Art. 16(2)(a).
|
|
172
|
+
Maps to the Report `message` column. Editable. %>
|
|
173
|
+
<div class="moderate-field">
|
|
174
|
+
<%= form.label :message, t("moderate.notices.fields.explanation", default: "Explanation") %>
|
|
175
|
+
<%= form.text_area :message, rows: 6, required: true %>
|
|
176
|
+
<span class="moderate-hint">
|
|
177
|
+
<%= t("moderate.notices.hints.explanation", default: "Explain why you believe this content is illegal, with enough detail for us to assess it.") %>
|
|
178
|
+
</span>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<%# EU member state — establishes jurisdiction and routing. Editable. %>
|
|
182
|
+
<div class="moderate-field">
|
|
183
|
+
<%= form.label :legal_country_code, t("moderate.notices.fields.member_state", default: "EU member state where this is illegal") %>
|
|
184
|
+
<%= form.select :legal_country_code,
|
|
185
|
+
member_states.map { |code| [t("moderate.member_states.#{code}", default: code.to_s), code] },
|
|
186
|
+
{ prompt: t("moderate.notices.prompts.member_state", default: "Select a country") },
|
|
187
|
+
required: true %>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<%#
|
|
191
|
+
Notifier identity — Art. 16(2)(c). PREFILLED + LOCKED when a Devise
|
|
192
|
+
`current_user` is signed in: the fields render readonly so a logged-in notifier
|
|
193
|
+
can't swap in someone else's name/email, and the controller re-asserts the
|
|
194
|
+
value server-side on submit (a tampered request can't spoof identity onto a
|
|
195
|
+
legal notice). For an anonymous notifier the fields are blank and editable.
|
|
196
|
+
Name is required EXCEPT for `protection_of_minors` notices, where the DSA
|
|
197
|
+
permits anonymity (the model enforces that carve-out).
|
|
198
|
+
https://eur-lex.europa.eu/eli/reg/2022/2065/oj (Art. 16(2)(c))
|
|
199
|
+
%>
|
|
200
|
+
<div class="moderate-field">
|
|
201
|
+
<%= form.label :notifier_name, t("moderate.notices.fields.notifier_name", default: "Your name") %>
|
|
202
|
+
<%= form.text_field :notifier_name, readonly: identity_locked %>
|
|
203
|
+
<span class="moderate-hint">
|
|
204
|
+
<% if identity_locked %>
|
|
205
|
+
<%= t("moderate.notices.hints.identity_locked", default: "Taken from your account.") %>
|
|
206
|
+
<% else %>
|
|
207
|
+
<%= t("moderate.notices.hints.notifier_name", default: "Optional only for notices about offences involving minors, where the law permits anonymity.") %>
|
|
208
|
+
<% end %>
|
|
209
|
+
</span>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<div class="moderate-field">
|
|
213
|
+
<%= form.label :notifier_email, t("moderate.notices.fields.notifier_email", default: "Your email") %>
|
|
214
|
+
<%= form.email_field :notifier_email, required: true, readonly: identity_locked %>
|
|
215
|
+
<span class="moderate-hint">
|
|
216
|
+
<% if identity_locked %>
|
|
217
|
+
<%= t("moderate.notices.hints.identity_locked", default: "Taken from your account.") %>
|
|
218
|
+
<% else %>
|
|
219
|
+
<%= t("moderate.notices.hints.notifier_email", default: "We send the confirmation of receipt and our decision here.") %>
|
|
220
|
+
<% end %>
|
|
221
|
+
</span>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<%# Good-faith attestation — Art. 16(2)(d). MUST be checked; the model rejects
|
|
225
|
+
the save otherwise (`good_faith_confirmed` has `acceptance: true`). %>
|
|
226
|
+
<div class="moderate-field moderate-checkbox">
|
|
227
|
+
<%= form.check_box :good_faith_confirmed, required: true %>
|
|
228
|
+
<%= form.label :good_faith_confirmed,
|
|
229
|
+
t("moderate.notices.fields.good_faith", default: "I confirm in good faith that the information and allegations in this notice are accurate and complete.") %>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
<%#
|
|
233
|
+
Cloudflare Turnstile widget — rendered ONLY when the host has the
|
|
234
|
+
`rails_cloudflare_turnstile` gem installed. When present, the gem mixes
|
|
235
|
+
RailsCloudflareTurnstile::ViewHelpers into ActionView (via its railtie), so
|
|
236
|
+
`cloudflare_turnstile` (the widget) and `cloudflare_turnstile_script_tag` (the
|
|
237
|
+
loader script) are available here and we render both automatically — the host
|
|
238
|
+
configures nothing beyond the gem + its keys. The controller auto-verifies the
|
|
239
|
+
challenge server-side (`validate_cloudflare_turnstile` before_action). When the
|
|
240
|
+
gem is ABSENT this block is skipped entirely and the gate falls back to
|
|
241
|
+
`config.notice_guard` (no-op by default), so the form just works.
|
|
242
|
+
https://github.com/instrumentl/rails-cloudflare-turnstile (README)
|
|
243
|
+
%>
|
|
244
|
+
<% if turnstile_widget_required? && respond_to?(:cloudflare_turnstile) %>
|
|
245
|
+
<div class="moderate-field">
|
|
246
|
+
<%= cloudflare_turnstile_script_tag if respond_to?(:cloudflare_turnstile_script_tag) %>
|
|
247
|
+
<%= cloudflare_turnstile %>
|
|
248
|
+
</div>
|
|
249
|
+
<% end %>
|
|
250
|
+
|
|
251
|
+
<div class="moderate-field">
|
|
252
|
+
<%= form.submit t("moderate.notices.submit", default: "Submit notice") %>
|
|
253
|
+
</div>
|
|
254
|
+
<% end %>
|
|
255
|
+
</div>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<%
|
|
2
|
+
rows = rows.to_h.compact_blank
|
|
3
|
+
translation_scope = local_assigns[:translation_scope]
|
|
4
|
+
%>
|
|
5
|
+
|
|
6
|
+
<article class="moderate-card">
|
|
7
|
+
<h2><%= title %></h2>
|
|
8
|
+
<% if rows.any? %>
|
|
9
|
+
<dl>
|
|
10
|
+
<% rows.sort_by { |key, _value| key.to_s }.each do |key, value| %>
|
|
11
|
+
<div>
|
|
12
|
+
<dt><%= translation_scope ? t("#{translation_scope}.#{key}", default: key.to_s.humanize) : key.to_s.humanize %></dt>
|
|
13
|
+
<dd><%= value %></dd>
|
|
14
|
+
</div>
|
|
15
|
+
<% end %>
|
|
16
|
+
</dl>
|
|
17
|
+
<% else %>
|
|
18
|
+
<p><%= t("moderate.transparency.no_data", default: "No data in this period.") %></p>
|
|
19
|
+
<% end %>
|
|
20
|
+
</article>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
.moderate-page { color: var(--moderate-text, #111827); font-family: var(--moderate-font-family, system-ui, sans-serif); }
|
|
3
|
+
.moderate-hero { background: var(--moderate-accent, #facc15); padding: 3rem 1.25rem; }
|
|
4
|
+
.moderate-container { max-width: 56rem; margin: 0 auto; }
|
|
5
|
+
.moderate-eyebrow { margin: 0; font-size: .75rem; font-weight: 800; text-transform: uppercase; letter-spacing: .12em; color: var(--moderate-muted-text, #374151); }
|
|
6
|
+
.moderate-title { margin: .5rem 0 0; font-size: clamp(2rem, 6vw, 2.5rem); line-height: 1.1; font-weight: 900; }
|
|
7
|
+
.moderate-period { margin-top: 1rem; font-size: .875rem; font-weight: 700; color: var(--moderate-muted-text, #374151); }
|
|
8
|
+
.moderate-main { padding: 2.5rem 1.25rem; }
|
|
9
|
+
.moderate-grid { display: grid; gap: 1.5rem; }
|
|
10
|
+
@media (min-width: 768px) { .moderate-grid { grid-template-columns: 1fr 1fr; } }
|
|
11
|
+
.moderate-card { border: 1px solid rgba(17, 24, 39, .08); border-radius: .5rem; background: white; padding: 1.5rem; box-shadow: 0 1px 2px rgba(17, 24, 39, .04); }
|
|
12
|
+
.moderate-card h2 { margin: 0; font-size: 1.125rem; font-weight: 900; }
|
|
13
|
+
.moderate-card dl { display: grid; gap: .75rem; margin: 1rem 0 0; font-size: .875rem; }
|
|
14
|
+
.moderate-card dl > div { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
|
15
|
+
.moderate-card dt { color: var(--moderate-muted-text, #4b5563); }
|
|
16
|
+
.moderate-card dd { margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-weight: 800; }
|
|
17
|
+
.moderate-card p { margin: 1rem 0 0; color: var(--moderate-muted-text, #6b7280); font-size: .875rem; }
|
|
18
|
+
</style>
|
|
19
|
+
|
|
20
|
+
<div class="moderate-page">
|
|
21
|
+
<section class="moderate-hero">
|
|
22
|
+
<div class="moderate-container">
|
|
23
|
+
<p class="moderate-eyebrow"><%= t("moderate.transparency.eyebrow", default: "Moderation") %></p>
|
|
24
|
+
<h1 class="moderate-title"><%= t("moderate.transparency.title", default: "Moderation transparency") %></h1>
|
|
25
|
+
<p class="moderate-period"><%= t("moderate.transparency.period", default: "Period: %{start} - %{end}", start: l(@period_start.to_date, format: :long), end: l(@period_end.to_date, format: :long)) %></p>
|
|
26
|
+
</div>
|
|
27
|
+
</section>
|
|
28
|
+
|
|
29
|
+
<main class="moderate-main">
|
|
30
|
+
<div class="moderate-container moderate-grid">
|
|
31
|
+
<%= render "summary_card", title: t("moderate.transparency.cards.notices_by_intake", default: "Notices by intake"), rows: @summary[:notices_by_intake] %>
|
|
32
|
+
<%= render "summary_card", title: t("moderate.transparency.cards.dsa_by_reason", default: "DSA notices by legal reason"), rows: @summary[:dsa_notices_by_legal_reason], translation_scope: "moderation.legal_reasons" %>
|
|
33
|
+
<%= render "summary_card", title: t("moderate.transparency.cards.actions_by_basis", default: "Actions by basis"), rows: @summary[:actions_by_basis], translation_scope: "moderation.resolution_bases" %>
|
|
34
|
+
<%= render "summary_card", title: t("moderate.transparency.cards.flags_by_source", default: "Automated flags by source"), rows: @summary[:automated_flags_by_source] %>
|
|
35
|
+
<%= render "summary_card", title: t("moderate.transparency.cards.appeals_by_status", default: "Appeals by status"), rows: @summary[:appeals_by_status] %>
|
|
36
|
+
|
|
37
|
+
<article class="moderate-card">
|
|
38
|
+
<h2><%= t("moderate.transparency.cards.median_times", default: "Median times") %></h2>
|
|
39
|
+
<dl>
|
|
40
|
+
<div>
|
|
41
|
+
<dt><%= t("moderate.transparency.notice_to_decision", default: "Notice to decision") %></dt>
|
|
42
|
+
<dd><%= distance_of_time_in_words(@summary[:median_notice_action_seconds].seconds) %></dd>
|
|
43
|
+
</div>
|
|
44
|
+
<div>
|
|
45
|
+
<dt><%= t("moderate.transparency.appeal_to_decision", default: "Appeal to decision") %></dt>
|
|
46
|
+
<dd><%= distance_of_time_in_words(@summary[:median_appeal_action_seconds].seconds) %></dd>
|
|
47
|
+
</div>
|
|
48
|
+
</dl>
|
|
49
|
+
</article>
|
|
50
|
+
</div>
|
|
51
|
+
</main>
|
|
52
|
+
</div>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# English blocklist for the built-in :wordlist filter adapter
|
|
2
|
+
# (Moderate::Filters::Wordlist).
|
|
3
|
+
#
|
|
4
|
+
# ── Format ───────────────────────────────────────────────────────────────────
|
|
5
|
+
# Top-level keys are CANONICAL TAXONOMY categories (see Moderate::Label::TAXONOMY,
|
|
6
|
+
# itself the OpenAI omni-moderation-latest taxonomy:
|
|
7
|
+
# https://developers.openai.com/api/docs/guides/moderation). Using the canonical
|
|
8
|
+
# vocabulary here — instead of the ad-hoc bucket names a host might invent — is the
|
|
9
|
+
# whole point: every adapter (this offline wordlist, the OpenAI endpoint, a host's
|
|
10
|
+
# own backend) emits the SAME category set, so Moderate::Flag, the DSA Art. 17
|
|
11
|
+
# statement of reasons, and the Art. 24 transparency counters all aggregate over
|
|
12
|
+
# one set. A category may be a bare top-level slug ("hate") or a slash-qualified
|
|
13
|
+
# subcategory ("hate/threatening") — both are valid canonical slugs.
|
|
14
|
+
#
|
|
15
|
+
# Each category maps to a list of REGEX patterns (Ruby Regexp source strings).
|
|
16
|
+
# Patterns are matched against NORMALIZED text — already lowercased, NFKD-folded
|
|
17
|
+
# (accents stripped), leetspeak-transliterated (0->o, 3->e, @->a, ...), and
|
|
18
|
+
# collapsed to single spaces — AND against the same text with all spaces removed
|
|
19
|
+
# (to defeat "f u c k"-style spacing evasion). So write patterns in lowercase,
|
|
20
|
+
# ASCII, space-separated form; the adapter handles the evasion-resistance.
|
|
21
|
+
#
|
|
22
|
+
# ── Why labels, not the raw word, are stored ─────────────────────────────────
|
|
23
|
+
# The adapter records the matched CATEGORY, never the offending substring, so a
|
|
24
|
+
# validation error or a queue entry never echoes abusive language back into the UI
|
|
25
|
+
# or the logs. (Same discipline as Apple Guideline 1.2 / Google Play UGC reviews
|
|
26
|
+
# expect: https://developer.apple.com/app-store/review/guidelines/#user-generated-content
|
|
27
|
+
# https://support.google.com/googleplay/android-developer/answer/9876937)
|
|
28
|
+
#
|
|
29
|
+
# ── This is a SEED, not a complete classifier ────────────────────────────────
|
|
30
|
+
# A static wordlist is a fast, offline, multilingual FIRST line of defense — it
|
|
31
|
+
# satisfies the store bar of "a method for filtering objectionable UGC before
|
|
32
|
+
# posting" and catches the obvious cases with zero dependencies and zero latency.
|
|
33
|
+
# It is deliberately small and conservative (false positives block real users).
|
|
34
|
+
# For nuanced, context-aware moderation, register the :openai adapter or your own.
|
|
35
|
+
# Extend without editing the gem via `config.additional_words` /
|
|
36
|
+
# `config.excluded_words`.
|
|
37
|
+
|
|
38
|
+
# Slurs / dehumanizing abuse directed at protected groups -> hate.
|
|
39
|
+
# Write plain word-boundary patterns; the adapter's normalization (NFKD fold,
|
|
40
|
+
# leetspeak, punctuation collapse) plus the compact-form match handle spacing and
|
|
41
|
+
# symbol evasion, so there's no need to hand-encode "n i g g e r" variants here.
|
|
42
|
+
hate:
|
|
43
|
+
- "\\bnigg(er|a)\\b"
|
|
44
|
+
- "\\bfagg?ot\\b"
|
|
45
|
+
- "\\bkike\\b"
|
|
46
|
+
- "\\bspic\\b"
|
|
47
|
+
- "\\bchink\\b"
|
|
48
|
+
- "\\btrann(y|ie)\\b"
|
|
49
|
+
|
|
50
|
+
# Threats of violence / encouraging self-harm of another -> hate/threatening.
|
|
51
|
+
# (Direct threats and incitement to self-harm, e.g. "I'm going to kill you" / "kys".)
|
|
52
|
+
hate/threatening:
|
|
53
|
+
- "\\bkill\\s+yourself\\b"
|
|
54
|
+
- "\\bkys\\b"
|
|
55
|
+
- "\\bi\\s*(am|m)\\s+going\\s+to\\s+kill\\s+you\\b"
|
|
56
|
+
|
|
57
|
+
# Insults / demeaning abuse aimed at an individual -> harassment.
|
|
58
|
+
harassment:
|
|
59
|
+
- "\\bbitch\\b"
|
|
60
|
+
- "\\bcunt\\b"
|
|
61
|
+
- "\\bfuck(er|ing|ed)?\\b"
|
|
62
|
+
- "\\basshole\\b"
|
|
63
|
+
- "\\bretard(ed)?\\b"
|
|
64
|
+
- "\\bwh[o0]re\\b"
|
|
65
|
+
|
|
66
|
+
# Explicit sexual content -> sexual.
|
|
67
|
+
sexual:
|
|
68
|
+
- "\\bporn(o|ography)?\\b"
|
|
69
|
+
- "\\bnude\\s+pics?\\b"
|
|
70
|
+
- "\\bexplicit\\s+sex\\b"
|
|
71
|
+
|
|
72
|
+
# Graphic depiction of violence -> violence/graphic.
|
|
73
|
+
violence/graphic:
|
|
74
|
+
- "\\bgore\\b"
|
|
75
|
+
- "\\bbeheading\\b"
|
|
76
|
+
|
|
77
|
+
# Solicitation of illegal/regulated goods or scams -> illicit.
|
|
78
|
+
illicit:
|
|
79
|
+
- "\\bfree\\s+crypto\\b"
|
|
80
|
+
- "\\bquick\\s+money\\b"
|
|
81
|
+
- "\\bwhatsapp\\s+casino\\b"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Spanish blocklist for the built-in :wordlist filter adapter
|
|
2
|
+
# (Moderate::Filters::Wordlist).
|
|
3
|
+
#
|
|
4
|
+
# Same format and rules as en.yml — see that file's header for the full
|
|
5
|
+
# explanation. In short: top-level keys are CANONICAL TAXONOMY categories
|
|
6
|
+
# (Moderate::Label::TAXONOMY / OpenAI omni-moderation-latest:
|
|
7
|
+
# https://developers.openai.com/api/docs/guides/moderation); values are Ruby
|
|
8
|
+
# Regexp source strings matched against normalized (lowercased, NFKD-folded,
|
|
9
|
+
# leetspeak-transliterated, single-spaced) text AND its space-stripped form.
|
|
10
|
+
#
|
|
11
|
+
# This list ships alongside en.yml because the wordlist adapter is "multilingual
|
|
12
|
+
# by default": it loads and merges EVERY bundled locale's list, so an app with a
|
|
13
|
+
# mixed-language audience is covered without configuration. Shipping a real
|
|
14
|
+
# Spanish list (not just English) is deliberate. Write patterns lowercase + ASCII:
|
|
15
|
+
# accents are folded out before matching, so "cabrón" is matched by "cabron".
|
|
16
|
+
|
|
17
|
+
# Insultos / abuso degradante hacia una persona -> harassment.
|
|
18
|
+
harassment:
|
|
19
|
+
- "\\bputa\\b"
|
|
20
|
+
- "\\bputo\\b"
|
|
21
|
+
- "\\bgilipollas\\b"
|
|
22
|
+
- "\\bcabr[o0]n\\b"
|
|
23
|
+
- "\\bzorra\\b"
|
|
24
|
+
- "\\bmaric[o0]n\\b"
|
|
25
|
+
|
|
26
|
+
# Amenazas de violencia -> hate/threatening.
|
|
27
|
+
hate/threatening:
|
|
28
|
+
- "\\bte\\s+voy\\s+a\\s+matar\\b"
|
|
29
|
+
- "\\bvoy\\s+a\\s+matarte\\b"
|
|
30
|
+
|
|
31
|
+
# Contenido sexual explícito -> sexual.
|
|
32
|
+
sexual:
|
|
33
|
+
- "\\bporn[o]?\\s+explicit[o0]\\b"
|
|
34
|
+
- "\\bsex[o0]\\s+explicit[o0]\\b"
|
|
35
|
+
|
|
36
|
+
# Solicitud de bienes ilícitos / estafas -> illicit.
|
|
37
|
+
illicit:
|
|
38
|
+
- "\\bcrypto\\s+gratis\\b"
|
|
39
|
+
- "\\bdinero\\s+rapido\\b"
|
|
40
|
+
- "\\bwhatsapp\\s+casino\\b"
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The engine's routes. They are RELATIVE on purpose: the engine declares only the
|
|
4
|
+
# resource, and the HOST chooses the mount point in its own routes —
|
|
5
|
+
#
|
|
6
|
+
# mount Moderate::Engine => "/trust" # => form at /trust/notices/new
|
|
7
|
+
# mount Moderate::Engine => "/moderation" # => form at /moderation/notices/new
|
|
8
|
+
# mount Moderate::Engine => "/dsa" # => form at /dsa/notices/new
|
|
9
|
+
#
|
|
10
|
+
# We deliberately do NOT hardcode a "/legal" prefix here (or anywhere). The path is
|
|
11
|
+
# the host's call; the gem only owns the routes RELATIVE to wherever it's mounted.
|
|
12
|
+
#
|
|
13
|
+
# Because the engine is isolated, these become the `moderate.` URL-helper proxy in
|
|
14
|
+
# the host (e.g. `moderate.new_notice_path`). NOTHING here is required to use the
|
|
15
|
+
# rest of the gem (report/block/filter/queue) — mounting is only for the public
|
|
16
|
+
# DSA Art. 16 notice form. See docs/dsa-notice-form.md.
|
|
17
|
+
Moderate::Engine.routes.draw do
|
|
18
|
+
# The public DSA "notice and action" form.
|
|
19
|
+
# GET /notices/new — the form (prefillable via query params; see the controller)
|
|
20
|
+
# POST /notices — submit (validate + persist a dsa-kind Moderate::Report +
|
|
21
|
+
# confirm receipt, Art. 16(4))
|
|
22
|
+
#
|
|
23
|
+
# Only :new and :create — a notice is a `Moderate::Report` with no public,
|
|
24
|
+
# enumerable identifier, so there is no per-notice `show` page; on success the
|
|
25
|
+
# controller redirects back to the form with a confirmation flash (the durable,
|
|
26
|
+
# on-record proof of receipt is the report's `acknowledged_at`, and the human-
|
|
27
|
+
# facing confirmation goes out through the `notice_received` notify hook).
|
|
28
|
+
resources :notices, only: %i[new create]
|
|
29
|
+
resources :appeals, only: %i[new create]
|
|
30
|
+
resource :transparency, only: :show, controller: "transparency_reports"
|
|
31
|
+
|
|
32
|
+
# The engine root redirects to the form, so mounting the engine makes its mount
|
|
33
|
+
# point itself a sensible landing spot (and a fine place to host the DSA Art.
|
|
34
|
+
# 11/12 "point of contact" copy once the views are ejected).
|
|
35
|
+
root to: "notices#new"
|
|
36
|
+
end
|