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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +8 -0
  3. data/.simplecov +62 -0
  4. data/AGENTS.md +7 -0
  5. data/Appraisals +16 -0
  6. data/CHANGELOG.md +109 -1
  7. data/CLAUDE.md +7 -0
  8. data/README.md +378 -29
  9. data/Rakefile +28 -2
  10. data/app/controllers/concerns/moderate/moderation.rb +161 -0
  11. data/app/controllers/moderate/appeals_controller.rb +190 -0
  12. data/app/controllers/moderate/application_controller.rb +45 -0
  13. data/app/controllers/moderate/notices_controller.rb +382 -0
  14. data/app/controllers/moderate/transparency_reports_controller.rb +30 -0
  15. data/app/helpers/moderate/engine_helper.rb +151 -0
  16. data/app/views/moderate/appeals/new.html.erb +78 -0
  17. data/app/views/moderate/notices/new.html.erb +255 -0
  18. data/app/views/moderate/transparency_reports/_summary_card.html.erb +20 -0
  19. data/app/views/moderate/transparency_reports/show.html.erb +52 -0
  20. data/config/moderate/blocklists/en.yml +81 -0
  21. data/config/moderate/blocklists/es.yml +40 -0
  22. data/config/routes.rb +36 -0
  23. data/context7.json +4 -0
  24. data/docs/compliance.md +178 -0
  25. data/docs/configuration.md +326 -0
  26. data/docs/dsa-notice-form.md +371 -0
  27. data/docs/images/moderate-user-report-block-actions.webp +0 -0
  28. data/docs/madmin.md +490 -0
  29. data/docs/notifications.md +363 -0
  30. data/examples/aws_rekognition_adapter.rb +140 -0
  31. data/examples/openai_moderation_adapter.rb +111 -0
  32. data/gemfiles/rails_7.1.gemfile +36 -0
  33. data/gemfiles/rails_7.2.gemfile +36 -0
  34. data/gemfiles/rails_8.1.gemfile +36 -0
  35. data/lib/generators/moderate/install_generator.rb +56 -0
  36. data/lib/generators/moderate/templates/create_moderate_tables.rb.erb +237 -0
  37. data/lib/generators/moderate/templates/initializer.rb +198 -0
  38. data/lib/generators/moderate/views_generator.rb +63 -0
  39. data/lib/moderate/configuration.rb +355 -0
  40. data/lib/moderate/engine.rb +138 -0
  41. data/lib/moderate/errors.rb +26 -0
  42. data/lib/moderate/event.rb +75 -0
  43. data/lib/moderate/filters/base.rb +126 -0
  44. data/lib/moderate/filters/wordlist.rb +255 -0
  45. data/lib/moderate/jobs/classify_job.rb +162 -0
  46. data/lib/moderate/label.rb +111 -0
  47. data/lib/moderate/macros.rb +90 -0
  48. data/lib/moderate/models/appeal.rb +154 -0
  49. data/lib/moderate/models/application_record.rb +31 -0
  50. data/lib/moderate/models/block.rb +203 -0
  51. data/lib/moderate/models/concerns/actor.rb +174 -0
  52. data/lib/moderate/models/concerns/content_filterable.rb +214 -0
  53. data/lib/moderate/models/concerns/reportable.rb +282 -0
  54. data/lib/moderate/models/flag.rb +169 -0
  55. data/lib/moderate/models/report.rb +620 -0
  56. data/lib/moderate/result.rb +176 -0
  57. data/lib/moderate/services/intake_appeal.rb +89 -0
  58. data/lib/moderate/services/intake_notice.rb +132 -0
  59. data/lib/moderate/services/intake_report.rb +132 -0
  60. data/lib/moderate/services/resolve_appeal.rb +134 -0
  61. data/lib/moderate/services/resolve_flag.rb +101 -0
  62. data/lib/moderate/services/resolve_report.rb +291 -0
  63. data/lib/moderate/version.rb +1 -1
  64. data/lib/moderate.rb +365 -18
  65. data/log/development.log +0 -0
  66. data/log/test.log +0 -0
  67. 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"