1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
|
# frozen_string_literal: true
class AbuseReport < ApplicationRecord
include CacheMarkdownField
include Sortable
include Gitlab::FileTypeDetection
include WithUploads
include Gitlab::Utils::StrongMemoize
include Mentionable
include Noteable
include IgnorableColumns
ignore_column :assignee_id, remove_with: '16.9', remove_after: '2024-01-19'
MAX_CHAR_LIMIT_URL = 512
MAX_FILE_SIZE = 1.megabyte
cache_markdown_field :message, pipeline: :single_line
belongs_to :reporter, class_name: 'User', inverse_of: :reported_abuse_reports
belongs_to :user, inverse_of: :abuse_reports
belongs_to :resolved_by, class_name: 'User', inverse_of: :resolved_abuse_reports
has_many :events, class_name: 'ResourceEvents::AbuseReportEvent', inverse_of: :abuse_report
has_many :label_links, as: :target, inverse_of: :target
has_many :labels, through: :label_links
has_many :admin_abuse_report_assignees, class_name: "Admin::AbuseReportAssignee"
has_many :assignees, class_name: "User", through: :admin_abuse_report_assignees
has_many :abuse_events, class_name: 'Abuse::Event', inverse_of: :abuse_report
has_many :notes, as: :noteable
has_many :user_mentions, class_name: 'Abuse::Reports::UserMention'
validates :reporter, presence: true, on: :create
validates :user, presence: true, on: :create
validates :message, presence: true
validates :category, presence: true
validates :user_id,
uniqueness: {
scope: [:reporter_id, :category],
message: ->(object, data) do
_('You have already reported this user')
end
}, on: :create
validates :reported_from_url,
allow_blank: true,
length: { maximum: MAX_CHAR_LIMIT_URL },
addressable_url: {
dns_rebind_protection: true,
blocked_message: 'is an invalid URL. You can try reporting the abuse again, ' \
'or contact a GitLab administrator for help.'
}
validates :links_to_spam,
allow_blank: true,
length: {
maximum: 20,
message: N_("exceeds the limit of %{count} links")
}
validates :mitigation_steps, length: { maximum: 1000 }, allow_blank: true
validates :evidence, json_schema: { filename: 'abuse_report_evidence' }, allow_blank: true
before_validation :filter_empty_strings_from_links_to_spam
validate :links_to_spam_contains_valid_urls
mount_uploader :screenshot, AttachmentUploader
validates :screenshot, file_size: { maximum: MAX_FILE_SIZE }
validate :validate_screenshot_is_image
scope :by_user_id, ->(user_id) { where(user_id: user_id) }
scope :by_reporter_id, ->(reporter_id) { where(reporter_id: reporter_id) }
scope :by_category, ->(category) { where(category: category) }
scope :with_users, -> { includes(:reporter, :user) }
scope :with_labels, -> { includes(:labels) }
enum category: {
spam: 1,
offensive: 2,
phishing: 3,
crypto: 4,
credentials: 5,
copyright: 6,
malware: 7,
other: 8
}
enum status: {
open: 1,
closed: 2
}
# For CacheMarkdownField
alias_method :author, :reporter
HUMANIZED_ATTRIBUTES = {
reported_from_url: "Reported from"
}.freeze
CONTROLLER_TO_REPORT_TYPE = {
'users' => :profile,
'projects/issues' => :issue,
'projects/merge_requests' => :merge_request
}.freeze
def self.human_attribute_name(attr, options = {})
HUMANIZED_ATTRIBUTES[attr.to_sym] || super
end
def remove_user(deleted_by:)
user.delete_async(deleted_by: deleted_by, params: { hard_delete: true })
end
def notify
return unless persisted?
AbuseReportMailer.notify(id).deliver_later
end
def screenshot_path
return unless screenshot
return screenshot.url unless screenshot.upload
asset_host = ActionController::Base.asset_host || Gitlab.config.gitlab.base_url
local_path = Gitlab::Routing.url_helpers.abuse_report_screenshot_path(
filename: screenshot.filename,
id: screenshot.upload.model_id,
model: 'abuse_report',
mounted_as: 'screenshot')
Gitlab::Utils.append_path(asset_host, local_path)
end
def report_type
type = CONTROLLER_TO_REPORT_TYPE[route_hash[:controller]]
type = :comment if type.in?([:issue, :merge_request]) && note_id_from_url.present?
type
end
def reported_content
case report_type
when :issue
reported_project.issues.iid_in(route_hash[:id]).pick(:description_html)
when :merge_request
reported_project.merge_requests.iid_in(route_hash[:id]).pick(:description_html)
when :comment
reported_project.notes.id_in(note_id_from_url).pick(:note_html)
end
end
def past_closed_reports_for_user
user.abuse_reports.closed.id_not_in(id)
end
def similar_open_reports_for_user
return AbuseReport.none unless open?
user.abuse_reports.open.by_category(category).id_not_in(id).includes(:reporter)
end
# createNote mutation calls noteable.project,
# which in case of abuse reports is nil
def project
nil
end
private
def reported_project
Project.find_by_full_path(route_hash.values_at(:namespace_id, :project_id).join('/'))
end
def reported_group
Group.find_by_full_path(route_hash[:group_id])
end
def route_hash
match = Rails.application.routes.recognize_path(reported_from_url)
return {} if match[:unmatched_route].present?
match
rescue ActionController::RoutingError
{}
end
strong_memoize_attr :route_hash
def note_id_from_url
fragment = URI(reported_from_url).fragment
Gitlab::UntrustedRegexp.new('^note_(\d+)$').match(fragment).to_a.second if fragment
rescue URI::InvalidURIError
nil
end
strong_memoize_attr :note_id_from_url
def filter_empty_strings_from_links_to_spam
return if links_to_spam.blank?
links_to_spam.reject!(&:empty?)
end
def links_to_spam_contains_valid_urls
return if links_to_spam.blank?
links_to_spam.each do |link|
Gitlab::UrlBlocker.validate!(
link,
schemes: %w[http https],
allow_localhost: true,
dns_rebind_protection: true
)
next unless link.length > MAX_CHAR_LIMIT_URL
errors.add(
:links_to_spam,
format(_('contains URLs that exceed the %{limit} character limit'), limit: MAX_CHAR_LIMIT_URL)
)
end
rescue ::Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError
errors.add(:links_to_spam, _('only supports valid HTTP(S) URLs'))
end
def filename
screenshot&.filename
end
def valid_image_extensions
Gitlab::FileTypeDetection::SAFE_IMAGE_EXT
end
def validate_screenshot_is_image
return if screenshot.blank?
return if image?
errors.add(
:screenshot,
format(
_('must match one of the following file types: %{extension_list}'),
extension_list: valid_image_extensions.to_sentence(last_word_connector: ' or '))
)
end
def self.aggregated_by_user_and_category(sort_by_count = false)
sub_query = self
.select('user_id, category, COUNT(id) as count', 'MIN(id) as min')
.group(:user_id, :category)
reports = AbuseReport.with_users
.open
.select('aggregated.*, status, id, reporter_id, created_at, updated_at')
.from(sub_query, :aggregated)
.joins('INNER JOIN abuse_reports on aggregated.min = abuse_reports.id')
if sort_by_count
reports.order(count: :desc, created_at: :desc)
else
reports
end
end
end
AbuseReport.prepend_mod
|