Repository navigation
Expand file tree
/
Copy pathuser.rb
More file actions
522 lines (424 loc) · 19.1 KB
/
Copy pathuser.rb
File metadata and controls
522 lines (424 loc) · 19.1 KB
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
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
class User < ApplicationRecord
has_one_attached :profile_og_image
include UserThemeConfiguration
include UserFuzzySearch
include ::OauthAuthentication
include ::SlackIntegration
include ::GithubIntegration
has_subscriptions
USERNAME_MAX_LENGTH = 21 # going over 21 overflows the navbar
DISPLAY_NAME_MAX_LENGTH = 80
has_paper_trail
after_create :subscribe_to_default_lists
after_create_commit :schedule_welcome_email
after_create_commit :schedule_onboarding_check_in_email
after_update_commit :clear_leaderboard_page_cache, if: -> { saved_change_to_leaderboard_shadowban_state? || saved_change_to_trust_level? || saved_change_to_leaderboard_opt_out? }
after_update_commit :clear_sailors_log_leaderboards, if: :saved_change_to_leaderboard_opt_out?
before_validation :normalize_username
before_validation :normalize_display_name_override
encrypts :slack_access_token, :github_access_token, :hca_access_token
validates :slack_uid, uniqueness: true, allow_nil: true
validates :github_uid, uniqueness: { conditions: -> { where.not(github_access_token: nil) } }, allow_nil: true
validates :timezone, inclusion: { in: TZInfo::Timezone.all_identifiers }, allow_nil: false
validates :country_code, inclusion: { in: ISO3166::Country.codes }, allow_nil: true
validates :username,
length: { maximum: USERNAME_MAX_LENGTH },
format: { with: /\A[A-Za-z0-9_-]+\z/, message: "may only include letters, numbers, '-', and '_'" },
uniqueness: { case_sensitive: false, message: "has already been taken" },
if: :will_save_change_to_username?,
allow_nil: true
validates :display_name_override, length: { maximum: DISPLAY_NAME_MAX_LENGTH }, allow_nil: true
validates :leaderboard_shadowban_reason, presence: true, if: :leaderboard_shadowbanned?
validate :leaderboard_shadowban_expiration_must_be_in_the_future
validate :username_must_be_visible
attribute :allow_public_stats_lookup, :boolean, default: true
attribute :default_timezone_leaderboard, :boolean, default: true
attribute :show_goals_in_statusbar, :boolean, default: true
def country_name = ISO3166::Country.new(country_code).common_name
enum :trust_level, {
blue: 0, # unscored
red: 1, # convicted
green: 2, # trusted
yellow: 3 # suspected (invisible to user)
}
def self.mask_trust_level(level)
level.to_s == "yellow" ? "blue" : level.to_s
end
def public_trust_level = self.class.mask_trust_level(trust_level)
enum :admin_level, {
default: 0, # pleebs
superadmin: 1,
admin: 2,
viewer: 3,
ultraadmin: 4
}, prefix: :admin_level
# Privilege ordering. The integer ordering on the enum is historical and does
# NOT correspond to privilege; never compare admin_level numerically. Use this
# rank table (or the helpers below) for any "outranks" check.
ADMIN_LEVEL_RANK = {
"default" => 0,
"viewer" => 1, # read-only admin
"admin" => 2,
"superadmin" => 3,
"ultraadmin" => 4
}.freeze
enum :theme, {
standard: 0,
midnight: 11,
neon: 1,
catppuccin_mocha: 2,
catppuccin_iced_latte: 3,
gruvbox_dark: 4,
github_dark: 5,
github_light: 6,
nord: 7,
rose: 8,
rose_pine_dawn: 9,
amoled: 10
}
# Look up a user by numeric ID, slack_uid, hca_id, or username
def self.lookup_by_identifier(id)
return nil if id.blank?
numeric_id = id.to_i if id.match?(/^\d+$/)
relation = where(slack_uid: id).or(where(hca_id: id)).or(where(username: id))
relation = where(id: numeric_id).or(relation) if numeric_id
candidates = relation.to_a
lookup_order = [
numeric_id && ->(u) { u.id == numeric_id },
->(u) { u.slack_uid == id },
->(u) { u.hca_id == id },
->(u) { u.username == id }
].compact
lookup_order.each { |matcher| match = candidates.find(&matcher); return match if match }
nil
end
def can_convict_users? = admin_level_superadmin? || admin_level_ultraadmin?
def can_leaderboard_shadowban_users? = admin_level.in?(%w[admin superadmin ultraadmin])
def can_view_query_stats? = admin_level.in?(%w[viewer admin superadmin ultraadmin])
def admin_level_rank = ADMIN_LEVEL_RANK[admin_level.to_s] || 0
def can_impersonate?(target_user)
admin_level.in?(%w[admin superadmin ultraadmin]) && can_act_on?(target_user)
end
# True if `self` is allowed to set `target_user`'s admin_level to `new_level`.
# Rules: only superadmin+ can change admin_level; no self-change; actor must
# strictly outrank target's current level and be at/above the rank being
# granted; granting `ultraadmin` requires `ultraadmin`.
def can_change_admin_level_of?(target_user, new_level)
return false unless ADMIN_LEVEL_RANK.key?(new_level.to_s)
return false unless admin_level_superadmin? || admin_level_ultraadmin?
return false unless can_act_on?(target_user)
return false unless admin_level_rank >= (ADMIN_LEVEL_RANK[new_level.to_s] || 0)
return false if new_level.to_s == "ultraadmin" && !admin_level_ultraadmin?
true
end
# True if `self` is allowed to set `target_user`'s trust_level to `new_level`.
# Rules: only admin+ can change trust; no self-change; actor must strictly
# outrank target; setting `red` requires `can_convict_users?`.
def can_change_trust_of?(target_user, new_level)
return false unless self.class.trust_levels.key?(new_level.to_s)
return false unless admin_level.in?(%w[admin superadmin ultraadmin])
return false unless can_act_on?(target_user)
return false if new_level.to_s == "red" && !can_convict_users?
true
end
# Change a user's admin_level. Returns false on auth failure or invalid level.
def set_admin_level(level, changed_by_user:) = change_level!(:admin_level, level, changed_by_user: changed_by_user)
# Change a user's trust_level. Returns false on auth failure or invalid level.
def set_trust(level, changed_by_user:, reason: nil, notes: nil)
change_level!(:trust_level, level, changed_by_user: changed_by_user) do |previous_level, new_level|
trust_level_audit_logs.create!(
changed_by: changed_by_user,
previous_trust_level: previous_level,
new_trust_level: new_level,
reason: reason,
notes: notes
)
end
end
# ex: .set_trust(:green, changed_by_user: admin) or set_trust(:red, changed_by_user: admin)
def set_leaderboard_shadowban(banned:, changed_by_user:, reason: nil, expires_at: nil)
return false unless changed_by_user.is_a?(User)
return false unless changed_by_user.can_leaderboard_shadowban_users?
return false if changed_by_user == self
return false unless changed_by_user.admin_level_rank > admin_level_rank
if banned
expires_at, expiry_valid = coerce_shadowban_expiration(expires_at)
unless expiry_valid
errors.add(:leaderboard_shadowban_expires_at, "is invalid")
return false
end
end
saved = update(
leaderboard_shadowbanned: banned,
leaderboard_shadowban_reason: banned ? reason.to_s.strip : nil,
leaderboard_shadowbanned_by: banned ? changed_by_user : nil,
leaderboard_shadowban_expires_at: banned ? expires_at : nil
)
schedule_leaderboard_shadowban_expiration if saved && banned && leaderboard_shadowban_expires_at.present?
saved
rescue ActiveRecord::ActiveRecordError => e
Rails.logger.error("set_leaderboard_shadowban failed for user #{id}: #{e.class}: #{e.message}")
false
end
has_many :heartbeats
has_many :heartbeat_exclusions, dependent: :destroy
has_one :active_poison, -> { active.poison }, class_name: "HeartbeatExclusion"
has_many :goals, dependent: :destroy
has_many :documentation_feedbacks, dependent: :destroy
has_many :email_addresses, dependent: :destroy
has_many :email_verification_requests, dependent: :destroy
has_many :sign_in_tokens, dependent: :destroy
has_many :project_repo_mappings
has_many :api_keys
has_many :admin_api_keys, dependent: :destroy
has_many :oauth_applications, as: :owner, dependent: :destroy
belongs_to :leaderboard_shadowbanned_by, class_name: "User", optional: true
has_one :sailors_log,
foreign_key: :slack_uid,
primary_key: :slack_uid,
class_name: "SailorsLog"
has_many :heartbeat_import_runs, dependent: :destroy
scope :search_identity, ->(term) {
term = term.to_s.strip
return none if term.blank?
contains = "%#{sanitize_sql_like(term)}%"
numeric_id = (term.match?(/\A\d+\z/) ? term.to_i : nil)
parts = [
"SELECT id FROM users WHERE slack_uid = :exact",
"SELECT id FROM users WHERE username ILIKE :contains",
"SELECT id FROM users WHERE slack_username ILIKE :contains",
"SELECT id FROM users WHERE github_username ILIKE :contains",
"SELECT user_id AS id FROM email_addresses WHERE email ILIKE :contains"
]
parts << "SELECT id FROM users WHERE id = #{numeric_id}" if numeric_id
candidates_sql = sanitize_sql_for_conditions([ parts.join(" UNION "), { exact: term, contains: contains } ])
where("users.id IN (#{candidates_sql})")
}
scope :leaderboard_shadowbanned, -> { where(leaderboard_shadowbanned: true) }
scope :leaderboard_participants, -> { where(leaderboard_opt_out: false) }
def saved_change_to_leaderboard_shadowban_state?
saved_change_to_leaderboard_shadowbanned? ||
saved_change_to_leaderboard_shadowban_reason? ||
saved_change_to_leaderboard_shadowbanned_by_id? ||
saved_change_to_leaderboard_shadowban_expires_at?
end
def clear_leaderboard_page_cache
LeaderboardPageCache.clear!
end
private def clear_sailors_log_leaderboards
SailorsLogLeaderboard.clear_cache!
end
def poisoned? = active_poison.present?
def apply_poison!(cutoff, reason: nil, by: nil)
ends_at = HeartbeatExclusion.poison_cutoff(cutoff, timezone:)
poison = transaction do
lock!
active_poison&.revoke!(by:)
heartbeat_exclusions.create!(kind: :poison, ends_at:, reason: reason.presence, created_by: by)
end
reset_active_poison
refresh_heartbeat_derived_data!
poison
end
def remove_poison!(by: nil)
revoked = transaction do
lock!
active_poison&.revoke!(by:)
end
reset_active_poison
return false unless revoked
refresh_heartbeat_derived_data!
true
end
private def refresh_heartbeat_derived_data!
@heartbeat_cache_version = nil
schedule_dashboard_rollup_refresh
discard_stale_leaderboard_entries!
clear_leaderboard_page_cache
end
private def discard_stale_leaderboard_entries!
Leaderboard::REBUILDABLE_PERIODS.each do |period|
date = LeaderboardDateRange.normalize_date(Date.current, period)
board = Leaderboard.find_by(start_date: date, period_type: period, timezone_utc_offset: nil, deleted_at: nil)
LeaderboardEntry.where(user_id: id, leaderboard_id: board.id).delete_all if board
LeaderboardUpdateJob.perform_later(period, date, force_update: true)
end
end
def schedule_leaderboard_shadowban_expiration
LeaderboardShadowbanExpirationJob.set(wait_until: leaderboard_shadowban_expires_at).perform_later(id)
end
has_many :trust_level_audit_logs, dependent: :destroy
has_many :trust_level_changes_made, class_name: "TrustLevelAuditLog", foreign_key: "changed_by_id", dependent: :destroy
has_many :deletion_requests, dependent: :restrict_with_error
has_many :deletion_approvals, class_name: "DeletionRequest", foreign_key: "admin_approved_by_id"
has_many :access_grants,
class_name: "Doorkeeper::AccessGrant",
foreign_key: :resource_owner_id,
dependent: :delete_all
has_many :access_tokens,
class_name: "Doorkeeper::AccessToken",
foreign_key: :resource_owner_id,
dependent: :delete_all
def streak_days = @streak_days ||= heartbeats.daily_streaks_for_users([ id ]).values.first
def active_deletion_request = deletion_requests.active.order(created_at: :desc).first
def pending_deletion? = active_deletion_request.present?
def api_access_restricted? = red? || pending_deletion?
def can_request_deletion?
return false if pending_deletion?
return true unless red?
last_audit = trust_level_audit_logs.where(new_trust_level: :red).order(created_at: :desc).first
return true unless last_audit
last_audit.created_at <= 365.days.ago
end
def can_delete_emails? = email_addresses.size > 1
def can_delete_email_address?(email) = email.can_unlink? && can_delete_emails?
def streak_days_formatted
streak_days > 30 ? "30+" : (streak_days < 1 ? nil : streak_days.to_s)
end
enum :hackatime_extension_text_type, {
simple_text: 0,
clock_emoji: 1,
compliment_text: 2
}
after_update_commit :invalidate_activity_graph_cache, if: :saved_change_to_timezone?
after_update_commit :schedule_dashboard_rollup_refresh, if: :saved_change_to_timezone?
def flipper_id = "User;#{id}"
def active_heartbeat_import_run? = heartbeat_import_runs.active_imports.exists?
def activity_graph_cache_key(timezone = self.timezone) = "user_#{id}_daily_durations_#{timezone}_#{heartbeat_cache_version}"
# Include in the cache key of any per-user data derived from heartbeats.
def heartbeat_cache_version = @heartbeat_cache_version ||= HeartbeatExclusion.cache_versions([ id ]).fetch(id)
def heartbeats_excluding_archived_projects
heartbeats.where(project: nil).or(heartbeats.where.not(project: project_repo_mappings.archived.select(:project_name)))
end
def format_extension_text(duration)
case hackatime_extension_text_type
when "simple_text"
return "Start coding!" if duration.zero?
::ApplicationController.helpers.short_time_simple(duration)
when "clock_emoji"
::ApplicationController.helpers.time_in_emoji(duration)
when "compliment_text"
compliments = FlavorText.compliment
bucket = Time.now.to_i / (7 * 60)
seed = Digest::MD5.hexdigest("#{id}-#{bucket}").to_i(16)
compliments[Random.new(seed).rand(compliments.length)]
end
end
def parse_and_set_timezone(timezone)
as_tz = ActiveSupport::TimeZone[timezone]
unless as_tz
begin
tzinfo = TZInfo::Timezone.get(timezone)
as_tz = ActiveSupport::TimeZone.all.find { |z| z.tzinfo.identifier == tzinfo.identifier }
rescue TZInfo::InvalidTimezoneIdentifier
end
end
as_tz ? (self.timezone = as_tz.name) : report_message("Invalid timezone #{timezone} for user #{id}")
end
def avatar_url
return self.slack_avatar_url if self.slack_avatar_url.present?
return self.github_avatar_url if self.github_avatar_url.present?
email = self.email_addresses&.first&.email
if email.present?
initials = email[0..1]&.upcase
hashed_initials = Digest::SHA256.hexdigest(initials)[0..5]
return "https://i2.wp.com/ui-avatars.com/api/#{initials}/48/#{hashed_initials}/fff?ssl=1"
end
"data:image/png;base64,#{RubyIdenticon.create_base64(id.to_s)}"
end
def display_name
return display_name_override if display_name_override.present?
name = slack_username || github_username || username
return name if name.present?
email = email_addresses&.first&.email
return "error displaying name" unless email.present?
email.split("@")&.first.truncate(10) + " (email sign-up)"
end
def most_recent_direct_entry_heartbeat = heartbeats.where(source_type: :direct_entry).order(time: :desc).first
def create_email_signin_token(continue_param: nil) = sign_in_tokens.create!(auth_type: :email, continue_param: continue_param)
def rotate_api_keys!
api_keys.transaction { api_keys.destroy_all; api_keys.create!(name: "Hackatime key") }
end
def rotate_single_api_key!(api_key)
raise ActiveRecord::RecordNotFound unless api_key.user_id == id
api_key.update!(token: SecureRandom.uuid_v4)
api_key
end
def find_valid_token(token) = sign_in_tokens.valid.find_by(token: token)
def self.not_convicted = where.not(trust_level: User.trust_levels[:red])
def self.not_suspect = where(trust_level: [ User.trust_levels[:blue], User.trust_levels[:green] ])
private
# Shared precondition for can_change_admin_level_of? / can_change_trust_of?
def can_act_on?(target_user)
return false unless target_user.is_a?(User)
return false if target_user == self
admin_level_rank > (ADMIN_LEVEL_RANK[target_user.admin_level.to_s] || 0)
end
# Shared change body for set_admin_level / set_trust. Yields (previous, new)
# when the value is actually changing, so callers can write audit logs.
def change_level!(attr, level, changed_by_user:)
return false unless changed_by_user.is_a?(User)
levels_map = self.class.public_send(attr.to_s.pluralize)
return false unless level.present? && levels_map.key?(level.to_s)
auth_method = attr == :admin_level ? :can_change_admin_level_of? : :can_change_trust_of?
return false unless changed_by_user.public_send(auth_method, self, level.to_s)
previous_level = public_send(attr)
if previous_level != level.to_s
yield(previous_level, level.to_s) if block_given?
update!(attr => level.to_s)
end
true
end
def invalidate_activity_graph_cache
previous_timezone, current_timezone = previous_changes.fetch("timezone", [ nil, timezone ])
[ previous_timezone, current_timezone ].compact.uniq.each do |cache_timezone|
Rails.cache.delete(activity_graph_cache_key(cache_timezone))
end
end
def schedule_dashboard_rollup_refresh = DashboardRollupRefreshJob.schedule_for(id, wait: 0.seconds)
def subscribe_to_default_lists = subscribe("weekly_summary")
def schedule_welcome_email
recipient_email = email_addresses.order(:id).pick(:email)
return if recipient_email.blank?
OnboardingMailer.welcome(self, recipient_email: recipient_email).deliver_later
end
def schedule_onboarding_check_in_email = OnboardingCheckInEmailJob.set(wait: 1.week).perform_later(id)
def normalize_username
original = username
@username_cleared_for_invisible = false
return if original.nil?
cleaned = original.gsub(/\p{Cf}/, "")
stripped = cleaned.strip
if stripped.empty?
self.username = nil
@username_cleared_for_invisible = original.length.positive?
else
self.username = stripped
end
end
def username_must_be_visible
return unless instance_variable_defined?(:@username_cleared_for_invisible) && @username_cleared_for_invisible
errors.add(:username, "must include visible characters")
end
def normalize_display_name_override
return if display_name_override.nil?
self.display_name_override = display_name_override.gsub(/\p{Cf}/, "").strip.presence
end
def leaderboard_shadowban_expiration_must_be_in_the_future
return unless will_save_change_to_leaderboard_shadowban_expires_at?
return unless leaderboard_shadowbanned?
return if leaderboard_shadowban_expires_at.blank?
return if leaderboard_shadowban_expires_at.future?
errors.add(:leaderboard_shadowban_expires_at, "must be in the future")
end
# returns [time_or_nil, valid?]. accepts a blank value (no expiration),
# an already-parsed time, or a string to parse.
def coerce_shadowban_expiration(value)
return [ nil, true ] if value.blank?
return [ value, true ] unless value.is_a?(String)
parsed = Time.zone.parse(value)
[ parsed, parsed.present? ]
rescue ArgumentError
[ nil, false ]
end
end