main
rb 183 lines 6.66 KB
Raw
1 # frozen_string_literal: true
2
3 class User < ApplicationRecord
4 has_many :repositories, dependent: :destroy
5 has_many :ssh_keys, dependent: :destroy
6 has_many :stars, dependent: :destroy
7 has_many :starred_repositories, through: :stars, source: :repository
8 has_one :subscription, dependent: :destroy
9 has_many :cloud_usages, dependent: :destroy
10 has_many :cloud_sessions, dependent: :destroy
11 has_one :github_connection, dependent: :destroy
12 has_many :repository_imports, dependent: :destroy
13
14 # True when the user has linked their GitHub account for imports.
15 def github_connected?
16 github_connection.present?
17 end
18
19 # Total reported size (KB) of imports currently in flight, used to enforce the
20 # per-user concurrent import volume cap without counting finished ones.
21 def active_import_size_kb
22 repository_imports.active.sum(:source_size_kb)
23 end
24
25 # Staff who can reach the /admin console. `admin` is a plain boolean column, so
26 # `admin?` is provided by ActiveRecord. Granted by hand — see `rake admin:grant`.
27 scope :admins, -> { where(admin: true) }
28
29 # Whether this user may use siGit Code Cloud (the paid cloud tiers). Free /
30 # unsubscribed users run on-device only.
31 def entitled_to_cloud?
32 subscription&.entitled_to_cloud? || false
33 end
34
35 # Cloud requests used / allowed in the current billing period.
36 def cloud_requests_used
37 return 0 unless subscription
38
39 CloudUsage.used(self, subscription.billing_period_key)
40 end
41
42 def cloud_allowance
43 subscription&.cloud_allowance || 0
44 end
45
46 # True while the user still has cloud allowance left this period.
47 def cloud_allowance_available?
48 cloud_requests_used < cloud_allowance
49 end
50
51 # Count one cloud request against this period. No-op without a subscription.
52 def record_cloud_request!
53 return unless subscription
54
55 CloudUsage.record_request!(self, subscription.billing_period_key)
56 end
57
58 validates :smbcloud_id,
59 presence: true,
60 uniqueness: true,
61 numericality: { only_integer: true, greater_than: 0 }
62
63 validates :email,
64 presence: true,
65 uniqueness: { case_sensitive: false },
66 format: { with: URI::MailTo::EMAIL_REGEXP, message: "is not a valid email address" }
67
68 validates :username,
69 presence: true,
70 uniqueness: { case_sensitive: false },
71 format: {
72 with: /\A[a-z0-9][a-z0-9\-]{0,37}[a-z0-9]?\z|\A[a-z0-9]\z/,
73 message: "may only contain lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen"
74 },
75 length: { minimum: 1, maximum: 39 }
76
77 # ---------------------------------------------------------------------------
78 # URL helpers
79 # ---------------------------------------------------------------------------
80
81 def to_param
82 username
83 end
84
85 # ---------------------------------------------------------------------------
86 # Display helpers
87 # ---------------------------------------------------------------------------
88
89 def display_name_or_username
90 display_name.presence || username
91 end
92
93 def avatar_url_or_default
94 return avatar_url if avatar_url.present?
95
96 encoded_name = URI.encode_www_form_component(display_name_or_username)
97 "https://ui-avatars.com/api/?name=#{encoded_name}&background=0057B8&color=fff&size=128"
98 end
99
100 OAUTH_PROVIDER_LABELS = { "github" => "GitHub", "google" => "Google" }.freeze
101
102 # Human label for the OAuth provider this account last signed in with, or
103 # nil if it has only ever used email/password sign-in.
104 def oauth_provider_label
105 OAUTH_PROVIDER_LABELS[oauth_provider]
106 end
107
108 # ---------------------------------------------------------------------------
109 # smbCloud Auth integration
110 # ---------------------------------------------------------------------------
111
112 # Upserts a user record from the data returned by:
113 # 1. SmbcloudAuthService.me(access_token:)
114 # → { id: Integer, email: String, created_at: String, updated_at: String }
115 # 2. The access_token passed through from login.
116 # 3. `oauth_provider`, when this upsert followed a brokered "Continue with
117 # <provider>" sign-in (e.g. "github", "google") — nil for plain
118 # email/password sign-in. Recorded so Settings can show which OAuth
119 # connection, if any, the account last used.
120 #
121 # Accepts either string or symbol keys.
122 #
123 # Returns the persisted User. Raises ActiveRecord::RecordInvalid on failure.
124 def self.find_or_create_from_smbcloud(userinfo, access_token: nil, oauth_provider: nil)
125 smbcloud_id = Integer(userinfo[:id] || userinfo["id"])
126 email = (userinfo[:email] || userinfo["email"]).to_s.strip.downcase
127
128 user = find_or_initialize_by(smbcloud_id: smbcloud_id)
129
130 # smbCloud is the identity authority. If we've never seen this smbcloud_id
131 # but the authenticated email already belongs to a local user, adopt that
132 # record and move it onto the new smbcloud_id rather than failing on the
133 # unique email constraint. The smbCloud auth_user id for an email can change
134 # (e.g. the account was recreated, or a social login resolved to a different
135 # auth_user than an older local record), and this can otherwise wedge both
136 # GitHub and email/password sign-in for that user.
137 if user.new_record? && email.present?
138 existing = find_by(email: email)
139 if existing
140 user = existing
141 user.smbcloud_id = smbcloud_id
142 end
143 end
144
145 # Always sync email and token in case they changed on the auth server.
146 user.email = email if email.present?
147 user.access_token = access_token if access_token.present?
148 user.oauth_provider = oauth_provider if oauth_provider.present?
149
150 # Derive a username only for new records.
151 user.username = generate_username_from_email(email) if user.new_record?
152
153 user.save!
154 user
155 end
156
157 # ---------------------------------------------------------------------------
158 # Private helpers
159 # ---------------------------------------------------------------------------
160
161 def self.generate_username_from_email(email)
162 base = email.to_s
163 .split("@")
164 .first
165 .to_s
166 .downcase
167 .gsub(/[^a-z0-9\-]/, "-") # replace non-slug chars with hyphens
168 .gsub(/-{2,}/, "-") # collapse consecutive hyphens
169 .gsub(/\A-+|-+\z/, "") # strip leading/trailing hyphens
170 .slice(0, 39)
171 .presence || "user"
172
173 return base unless exists?(username: base)
174
175 # Append a random 4-char suffix until we find an available handle.
176 loop do
177 suffix = SecureRandom.alphanumeric(4).downcase
178 candidate = "#{base.slice(0, 34)}-#{suffix}"
179 break candidate unless exists?(username: candidate)
180 end
181 end
182 private_class_method :generate_username_from_email
183 end