moderate 0.1.0 → 1.0.0.beta2
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 +109 -1
- data/CLAUDE.md +7 -0
- data/README.md +378 -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/context7.json +4 -0
- data/docs/compliance.md +178 -0
- data/docs/configuration.md +326 -0
- data/docs/dsa-notice-form.md +371 -0
- data/docs/images/moderate-user-report-block-actions.webp +0 -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 +355 -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 +162 -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 +214 -0
- data/lib/moderate/models/concerns/reportable.rb +282 -0
- data/lib/moderate/models/flag.rb +169 -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 +156 -15
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moderate
|
|
4
|
+
# View helpers exposed to BOTH the engine's own views and the HOST app's views.
|
|
5
|
+
#
|
|
6
|
+
# The headline helper is `moderate_report_link` (and its terser alias
|
|
7
|
+
# `report_link`): a one-liner you drop next to any reportable piece of content to
|
|
8
|
+
# render an in-app "Report" affordance — the exact thing Apple Guideline 1.2 and
|
|
9
|
+
# Google Play's UGC policy require every social/UGC app to surface next to
|
|
10
|
+
# user-generated content.
|
|
11
|
+
# - https://developer.apple.com/app-store/review/guidelines/#user-generated-content
|
|
12
|
+
# - https://support.google.com/googleplay/android-developer/answer/9876937
|
|
13
|
+
#
|
|
14
|
+
# GOTCHA — isolated engine + host views: `Moderate::Engine` is an *isolated*
|
|
15
|
+
# engine (`isolate_namespace Moderate`). Rails does NOT auto-include an isolated
|
|
16
|
+
# engine's helpers into the host application's views — that's the whole point of
|
|
17
|
+
# isolation. But `moderate_report_link` is meant to be called from the host's own
|
|
18
|
+
# templates (e.g. `<%= moderate_report_link(@comment, field: :body) %>`), so we
|
|
19
|
+
# explicitly mix this module into ActionView for the host at load time via the
|
|
20
|
+
# `ActiveSupport.on_load(:action_view)` hook at the bottom of this file. That is
|
|
21
|
+
# the same trick Devise uses to expose its url helpers app-wide.
|
|
22
|
+
module EngineHelper
|
|
23
|
+
# Render an in-app report affordance for `record`'s `field`, or NOTHING when the
|
|
24
|
+
# current viewer isn't allowed to report it.
|
|
25
|
+
#
|
|
26
|
+
# Renders nothing (returns nil) — deliberately, and in three cases:
|
|
27
|
+
# 1. there is no signed-in viewer (anonymous users use the public DSA notice
|
|
28
|
+
# form instead; this is the *in-app* button), or
|
|
29
|
+
# 2. the record doesn't know how to be reported (no `report_visible_to?`), or
|
|
30
|
+
# 3. the record says this viewer may not report this field
|
|
31
|
+
# (`record.report_visible_to?(viewer, field:)` is false — e.g. you can't
|
|
32
|
+
# report your own content, or content you can't even see).
|
|
33
|
+
#
|
|
34
|
+
# This "render nothing unless permitted" contract is what lets a host sprinkle
|
|
35
|
+
# the helper liberally across a template without guarding each call site — the
|
|
36
|
+
# helper is the guard. It mirrors the reference app's `report_link`.
|
|
37
|
+
#
|
|
38
|
+
# @param record [Object] any model that `include`s Moderate::Reportable
|
|
39
|
+
# @param field [Symbol, String, nil] which field is being reported (nil =>
|
|
40
|
+
# the whole record). Passed straight through to the visibility check and the
|
|
41
|
+
# intake form so the moderator sees exactly which field was flagged.
|
|
42
|
+
# @param label [String, nil] the visible link text. Defaults to a translated
|
|
43
|
+
# "Report" string so the host gets i18n for free.
|
|
44
|
+
# @param html_options [Hash] extra HTML attributes merged onto the <a> (class,
|
|
45
|
+
# data-*, aria-*, …) so the host can style it to taste without a wrapper.
|
|
46
|
+
# @return [ActiveSupport::SafeBuffer, nil]
|
|
47
|
+
def moderate_report_link(record, field: nil, label: nil, **html_options)
|
|
48
|
+
viewer = moderate_current_viewer
|
|
49
|
+
return if viewer.nil?
|
|
50
|
+
|
|
51
|
+
# The record gates its own reportability. We `respond_to?`-guard so a host can
|
|
52
|
+
# pass any object without the helper exploding — a non-reportable object simply
|
|
53
|
+
# renders nothing, same as "not permitted".
|
|
54
|
+
return unless record.respond_to?(:report_visible_to?)
|
|
55
|
+
return unless record.report_visible_to?(viewer, field: field)
|
|
56
|
+
|
|
57
|
+
label ||= moderate_report_default_label
|
|
58
|
+
link_to(label, moderate_report_path_for(record, field: field), **html_options)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Terse alias. The README/docs use `moderate_report_link` in host views (to
|
|
62
|
+
# avoid clashing with a host's own `report_link`), but `report_link` reads
|
|
63
|
+
# cleanest inside the engine's own templates. Same method, two names.
|
|
64
|
+
def report_link(record, field: nil, label: nil, **html_options)
|
|
65
|
+
moderate_report_link(record, field: field, label: label, **html_options)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# The path to the in-app intake form for this record/field.
|
|
71
|
+
#
|
|
72
|
+
# The target is passed as a SIGNED Global ID, never a raw `type`+`id`. This is
|
|
73
|
+
# the load-bearing security decision of the report flow: a raw polymorphic
|
|
74
|
+
# `reportable_type`/`reportable_id` pair in a URL is attacker-controlled — anyone
|
|
75
|
+
# could file a report against an arbitrary record (or probe which ids exist).
|
|
76
|
+
# A signed GID is tamper-proof and scoped to a single purpose, so the intake
|
|
77
|
+
# controller can `locate_signed_reportable` it back to the exact record the host
|
|
78
|
+
# chose to expose here, and nothing else.
|
|
79
|
+
# See: https://api.rubyonrails.org/classes/GlobalID/Identification.html#method-i-to_sgid
|
|
80
|
+
#
|
|
81
|
+
# NOTE: the in-app report controller/route is the host's (BYOUI) — `moderate`
|
|
82
|
+
# ships the primitives, not the in-app report UI. So we build the path from the
|
|
83
|
+
# host's named route (`new_report_path`/`new_moderate_report_path`) when present
|
|
84
|
+
# and fall back to a conventional `/reports/new?target=…` otherwise, rather than
|
|
85
|
+
# hard-coding the engine's routes (which only cover the *public* DSA form).
|
|
86
|
+
def moderate_report_path_for(record, field:)
|
|
87
|
+
target = moderate_signed_target(record)
|
|
88
|
+
query = { target: target, field: field.presence }.compact
|
|
89
|
+
|
|
90
|
+
if respond_to?(:new_report_path)
|
|
91
|
+
new_report_path(query)
|
|
92
|
+
elsif respond_to?(:new_moderate_report_path)
|
|
93
|
+
new_moderate_report_path(query)
|
|
94
|
+
else
|
|
95
|
+
# Last-resort conventional path so the helper is never a hard dependency on
|
|
96
|
+
# a particular route name. The host wires the actual route (BYOUI).
|
|
97
|
+
"/reports/new?#{query.to_query}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The record as a signed, purpose-scoped GID parameter. We delegate to the
|
|
102
|
+
# Reportable concern's own signer when available (so the purpose/expiry stay in
|
|
103
|
+
# ONE place — the model), and fall back to `to_sgid_param` with the canonical
|
|
104
|
+
# purpose otherwise.
|
|
105
|
+
def moderate_signed_target(record)
|
|
106
|
+
return record.to_moderation_sgid if record.respond_to?(:to_moderation_sgid)
|
|
107
|
+
|
|
108
|
+
# GlobalID's signed param, scoped to a stable purpose string so a token minted
|
|
109
|
+
# for reporting can't be replayed against an unrelated signed-GID feature.
|
|
110
|
+
#
|
|
111
|
+
# `expires_in: nil` mints a NON-EXPIRING token on purpose. By default
|
|
112
|
+
# `to_sgid_param` bakes in an `exp` timestamp (SignedGlobalID.expires_in, ~1
|
|
113
|
+
# month), which makes the token — and therefore the rendered link's URL —
|
|
114
|
+
# CHANGE on every render. That breaks HTTP/fragment caching of pages that show
|
|
115
|
+
# the report link, and makes the link non-deterministic. The token is already
|
|
116
|
+
# purpose-scoped ("moderate_report") and the locator restricts resolution to the
|
|
117
|
+
# allow-listed reportable classes, so a stable, non-expiring identifier is the
|
|
118
|
+
# right trade-off here: it identifies WHICH record to report, it doesn't grant
|
|
119
|
+
# any capability that needs to time out.
|
|
120
|
+
record.to_sgid_param(for: "moderate_report", expires_in: nil)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Who is viewing — resolved without coupling `moderate` to any auth gem. We try
|
|
124
|
+
# `current_user` (Devise/most apps) first; a host with a different actor accessor
|
|
125
|
+
# can override `moderate_current_viewer` in their own helper. nil => anonymous,
|
|
126
|
+
# which correctly hides the in-app button.
|
|
127
|
+
def moderate_current_viewer
|
|
128
|
+
return current_user if respond_to?(:current_user)
|
|
129
|
+
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Default link text, pulled through I18n so it localizes with the host's locale
|
|
134
|
+
# and can be overridden without touching call sites. Falls back to plain English
|
|
135
|
+
# if the host hasn't loaded the gem's locale file.
|
|
136
|
+
def moderate_report_default_label
|
|
137
|
+
if defined?(I18n)
|
|
138
|
+
I18n.t("moderate.report_link.label", default: "Report")
|
|
139
|
+
else
|
|
140
|
+
"Report"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Expose the helper to the HOST app's views (see the "isolated engine" gotcha in
|
|
147
|
+
# the module doc above). `on_load(:action_view)` defers until ActionView is loaded,
|
|
148
|
+
# so we never force it at boot and we play nice with the host's load order.
|
|
149
|
+
ActiveSupport.on_load(:action_view) do
|
|
150
|
+
include Moderate::EngineHelper
|
|
151
|
+
end if defined?(ActiveSupport)
|
|
@@ -0,0 +1,78 @@
|
|
|
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: 42rem; 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-main { padding: 2.5rem 1.25rem; }
|
|
8
|
+
.moderate-panel { margin-bottom: 1.5rem; border: 1px solid rgba(17, 24, 39, .08); border-radius: .5rem; background: var(--moderate-panel, #f9fafb); padding: 1rem; color: var(--moderate-muted-text, #374151); }
|
|
9
|
+
.moderate-form { display: grid; gap: 1.25rem; }
|
|
10
|
+
.moderate-grid { display: grid; gap: 1rem; }
|
|
11
|
+
@media (min-width: 640px) { .moderate-grid { grid-template-columns: 1fr 1fr; } }
|
|
12
|
+
.moderate-label { display: block; font-size: .875rem; font-weight: 700; }
|
|
13
|
+
.moderate-input { box-sizing: border-box; margin-top: .5rem; width: 100%; border: 1px solid rgba(17, 24, 39, .14); border-radius: .5rem; background: white; padding: .75rem 1rem; font: inherit; }
|
|
14
|
+
.moderate-error { border: 1px solid #fecaca; border-radius: .5rem; background: #fef2f2; padding: .75rem 1rem; color: #991b1b; font-size: .875rem; font-weight: 700; }
|
|
15
|
+
.moderate-button { width: 100%; border: 0; border-radius: 999px; background: var(--moderate-button, #111827); color: white; padding: .85rem 1rem; font: inherit; font-weight: 900; cursor: pointer; }
|
|
16
|
+
.moderate-turnstile { display: flex; justify-content: center; }
|
|
17
|
+
</style>
|
|
18
|
+
|
|
19
|
+
<div class="moderate-page">
|
|
20
|
+
<section class="moderate-hero">
|
|
21
|
+
<div class="moderate-container">
|
|
22
|
+
<p class="moderate-eyebrow"><%= t("moderate.appeals.eyebrow", default: "Moderation") %></p>
|
|
23
|
+
<h1 class="moderate-title"><%= t("moderate.appeals.title", default: "Appeal a decision") %></h1>
|
|
24
|
+
</div>
|
|
25
|
+
</section>
|
|
26
|
+
|
|
27
|
+
<main class="moderate-main">
|
|
28
|
+
<div class="moderate-container">
|
|
29
|
+
<div class="moderate-panel">
|
|
30
|
+
<p>
|
|
31
|
+
<strong><%= t("moderate.appeals.decision", default: "Decision") %></strong>
|
|
32
|
+
<span><%= @report.id %></span>
|
|
33
|
+
</p>
|
|
34
|
+
<% if @report.appeal_deadline_at.present? %>
|
|
35
|
+
<p><%= t("moderate.appeals.deadline", default: "You can request a free internal review until %{deadline}. A person will review it.", deadline: l(@report.appeal_deadline_at, format: :long)) %></p>
|
|
36
|
+
<% end %>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<%= form_with model: @appeal, url: appeals_path(token: params[:token]), scope: :appeal, class: "moderate-form" do |form| %>
|
|
40
|
+
<%= form.hidden_field :source, value: @appeal.source || "notifier" %>
|
|
41
|
+
|
|
42
|
+
<div class="moderate-grid">
|
|
43
|
+
<div>
|
|
44
|
+
<%= form.label :appellant_name, t("moderate.appeals.fields.name", default: "Name"), class: "moderate-label" %>
|
|
45
|
+
<%= form.text_field :appellant_name, class: "moderate-input" %>
|
|
46
|
+
</div>
|
|
47
|
+
<div>
|
|
48
|
+
<%= form.label :appellant_email, t("moderate.appeals.fields.email", default: "Email"), class: "moderate-label" %>
|
|
49
|
+
<%= form.email_field :appellant_email, required: true, class: "moderate-input" %>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div>
|
|
54
|
+
<%= form.label :reason, t("moderate.appeals.fields.reason", default: "Reason for appeal"), class: "moderate-label" %>
|
|
55
|
+
<%= form.text_area :reason,
|
|
56
|
+
rows: 8,
|
|
57
|
+
maxlength: Moderate::Report::MESSAGE_MAX_LENGTH,
|
|
58
|
+
required: true,
|
|
59
|
+
class: "moderate-input",
|
|
60
|
+
placeholder: t("moderate.appeals.placeholders.reason", default: "Explain why you believe the decision should be reviewed and include any useful context.") %>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<% if turnstile_widget_required? && respond_to?(:cloudflare_turnstile) %>
|
|
64
|
+
<div class="moderate-turnstile">
|
|
65
|
+
<%= cloudflare_turnstile_script_tag if respond_to?(:cloudflare_turnstile_script_tag) %>
|
|
66
|
+
<%= cloudflare_turnstile %>
|
|
67
|
+
</div>
|
|
68
|
+
<% end %>
|
|
69
|
+
|
|
70
|
+
<% if @appeal.errors.any? %>
|
|
71
|
+
<div class="moderate-error"><%= @appeal.errors.full_messages.to_sentence %></div>
|
|
72
|
+
<% end %>
|
|
73
|
+
|
|
74
|
+
<%= form.submit t("moderate.appeals.submit", default: "Submit appeal"), class: "moderate-button" %>
|
|
75
|
+
<% end %>
|
|
76
|
+
</div>
|
|
77
|
+
</main>
|
|
78
|
+
</div>
|
|
@@ -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"
|