forked from hotsh/rstat.us
/
user.rb
419 lines (334 loc) · 11.4 KB
/
user.rb
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
# The User model contains all of the information that a particular user of our
# site needs: their username/password, etc. It all comes from here. Even users
# that sign up via Twitter get a User model, though it's a bit empty in that
# particular case.
require 'crypto'
require 'bcrypt'
class User
require 'digest/md5'
include MongoMapper::Document
# Associations
# XXX: These don't seem to be getting set when you sign up with Twitter, etc?
many :authorizations, :dependent => :destroy
belongs_to :author
key :author_id, ObjectId
# Users MUST have a username
key :username, String, :required => true
# Users MIGHT have an email
key :email, String
key :email_confirmed, Boolean
# RSA for salmon usage
key :private_key, String
# Required for confirmation
key :perishable_token, String
# Tokens are valid for 2 days, they're checked against this
key :perishable_token_set, DateTime, :default => nil
# Global preference set via user's profile controlling the state of the Post to Twitter checkbox
key :always_send_to_twitter, Integer, :default => 1
validate :email_already_confirmed
validates_uniqueness_of :username,
:allow_nil => :true,
:case_sensitive => false,
:message => "has already been taken."
# The maximum is arbitrary
# Twitter has 15, let's be different
validates_length_of :username,
:maximum => 17,
:message => "must be 17 characters or fewer."
# Validate users don't have special characters in their username
validate :no_malformed_username
# This will establish other entities related to the User
after_create :finalize
# Mongo_mapper does not run :dependent => :destroy on belongs_to
# relationships, so clean up manually.
# https://github.com/jnunemaker/mongomapper/blob/master/test/functional/associations/test_belongs_to_proxy.rb#L155
before_destroy :clean_up
def clean_up
self.author.destroy
end
def feed
self.author.feed
end
def updates
self.author.feed.updates.sort(:created_at.desc)
end
# Before a user is created, we will generate some RSA keys
def generate_rsa_pair
keypair = Crypto.generate_keypair
self.author.public_key = keypair.public_key
self.author.save
self.private_key = keypair.private_key
end
# Retrieves a valid RSA::KeyPair for the User's private key
def to_rsa_keypair
Crypto.make_rsa_keypair(nil, private_key)
end
# After a user is created, create the feed and reset the token
def finalize
create_feed
generate_rsa_pair
reset_perishable_token
end
# Generate a multi-use token for account confirmation and password resets
def set_perishable_token
self.perishable_token = SecureRandom.hex
save
end
# Reset the perishable token and the date it was set to nil
def reset_perishable_token
self.perishable_token = nil
self.perishable_token_set = nil
save
end
# Determines a url that leads to the profile of this user
def url
"/users/#{feed.author.username}"
end
# Returns true when this user has a twitter authorization
def twitter?
has_authorization?(:twitter)
end
# Returns the twitter authorization
def twitter
get_authorization(:twitter)
end
# Check if a a user has a certain authorization by providing the associated
# provider
def has_authorization?(auth)
a = Authorization.first(:provider => auth.to_s, :user_id => self.id)
#return false if not authenticated and true otherwise.
!a.nil?
end
# Get an authorization by providing the assoaciated provider
def get_authorization(auth)
Authorization.first(:provider => auth.to_s, :user_id => self.id)
end
# Users follow many feeds
key :following_ids, Array
many :following, :in => :following_ids, :class_name => 'Feed'
# Users have feeds that follow them
key :followers_ids, Array
many :followers, :in => :followers_ids, :class_name => 'Feed'
# A particular feed follows this user
def followed_by!(f)
followers << f
save
end
# A particular feed unfollows this user
def unfollowed_by!(f)
followers_ids.delete(f.id)
save
end
# Follow a particular feed
def follow!(f)
# can't follow yourself
if f == self.feed
return
end
following << f
save
if f.local?
# Add the inverse relationship
followee = User.first(:author_id => f.author.id)
followee.followed_by! self.feed
else
# Queue a notification job
self.delay.send_follow_notification(f.id)
end
f
end
# Send Salmon notification so that the remote user
# knows this user is following them
def send_follow_notification(to_feed_id)
f = Feed.first :id => to_feed_id
salmon = OStatus::Salmon.from_follow(author.to_atom, f.author.to_atom)
envelope = salmon.to_xml self.to_rsa_keypair
# Send envelope to Author's Salmon endpoint
uri = URI.parse(f.author.salmon_url)
http = Net::HTTP.new(uri.host, uri.port)
if uri.scheme == "https"
http.use_ssl = (uri.port == 443)
end
res = http.post(uri.path, envelope, {"Content-Type" => "application/magic-envelope+xml"})
end
# unfollow takes a feed (since it is guaranteed to exist)
def unfollow!(followed_feed)
following_ids.delete(followed_feed.id)
save
if followed_feed.local?
followee = User.first(:author_id => followed_feed.author.id)
followee.unfollowed_by!(self.feed)
else
# Queue a notification job
self.delay.send_unfollow_notification(followed_feed.id)
end
end
# Send Salmon notification so that the remote user
# knows this user has stopped following them
def send_unfollow_notification(to_feed_id)
f = Feed.first :id => to_feed_id
salmon = OStatus::Salmon.from_unfollow(author.to_atom, f.author.to_atom)
envelope = salmon.to_xml self.to_rsa_keypair
# Send envelope to Author's Salmon endpoint
uri = URI.parse(f.author.salmon_url)
http = Net::HTTP.new(uri.host, uri.port)
if uri.scheme == "https"
http.use_ssl = (uri.port == 443)
end
res = http.post(uri.path, envelope, {"Content-Type" => "application/magic-envelope+xml"})
end
# Send an update to a remote user as a Salmon notification
def send_mention_notification(update_id, to_feed_id)
f = Feed.first :id => to_feed_id
u = Update.first :id => update_id
protocol = author.use_ssl ? "https" : "http"
base_uri = "#{protocol}://#{author.domain}/"
salmon = OStatus::Salmon.new(u.to_atom(base_uri))
envelope = salmon.to_xml self.to_rsa_keypair
# Send envelope to Author's Salmon endpoint
uri = URI.parse(f.author.salmon_url)
http = Net::HTTP.new(uri.host, uri.port)
if uri.scheme == "https"
http.use_ssl = (uri.port == 443)
end
res = http.post(uri.path, envelope, {"Content-Type" => "application/magic-envelope+xml"})
end
def autocomplete(query)
if query.nil? || query.blank?
return []
end
query = '^' + Regexp.escape(query) + '.*'
following.inject([]) do |result, obj|
if /#{query}/i =~ obj.author.fully_qualified_name
result << { :label => obj.author.fully_qualified_name.downcase }
end
result
end
end
def followed_by?(f)
followers.include? f
end
def following_feed?(f)
following.include? f
end
def following_author?(author)
following.include?(author.feed)
end
def following_url?(feed_url)
# Handle possibly created multiple feeds for the same remote_url
existing_feeds = Feed.all(:remote_url => feed_url)
# local feed?
if existing_feeds.empty? and feed_url.match(/^http[s]?:\/\/#{author.domain}\//)
feed_id = feed_url[/\/feeds\/(.+)$/,1]
existing_feeds = [Feed.first(:id => feed_id)]
end
if existing_feeds.empty?
false
else
# Intersect the feeds we're following and the possibly
# created multiple feeds for the remote
!(following & existing_feeds).empty?
end
end
timestamps!
# Retrieve the list of Updates in the user's timeline
def timeline
following_plus_me = following.map(&:author_id)
following_plus_me << self.author.id
Update.where(:author_id => following_plus_me).order(['created_at', 'descending'])
end
# Retrieve the list of Updates that are replies to this user
def at_replies
Update.where(:text => /^@#{Regexp.quote(username)}\b/).order(['created_at', 'descending'])
end
# User MUST be confirmed
key :status
# Users have a password
key :hashed_password, String
# Store the hash of the password
def password=(pass)
self.hashed_password = BCrypt::Password.create(pass, :cost => 10)
end
# Create a new perishable token and set the date the token was
# sent so tokens can be expired after 2 days. This is used for
# password resets and email confirmations
def create_token
self.perishable_token_set = DateTime.now
set_perishable_token
self.perishable_token
end
# Set a new password, clear the date the password reset token was sent and
# reset the perishable token
def reset_password(pass)
self.password = pass
reset_perishable_token
end
# Authenticate the user by checking their credentials
def self.authenticate(username, pass)
user = User.find_by_case_insensitive_username(username)
return nil if user.nil?
return nil unless user.hashed_password
return user if BCrypt::Password.new(user.hashed_password) == pass
nil
end
# Edit profile information
def update_profile!(params)
unless params[:password].nil? or params[:password].empty?
if params[:password] == params[:password_confirm]
self.password = params[:password]
self.save
else
self.errors.add(:password, "doesn't match confirmation.")
return false
end
end
params[:email] = nil if params[:email].blank?
self.username = params[:username]
self.email_confirmed = self.email == params[:email]
self.email = params[:email]
self.always_send_to_twitter = params[:user] && params[:user][:always_send_to_twitter].to_i
return false unless self.save
author.username = params[:username]
author.name = params[:name]
author.email = params[:email]
author.website = params[:website]
author.bio = params[:bio]
author.save
# TODO: Send out notice to other nodes
# To each remote domain that is following you via hub
# and to each remote domain that you follow via salmon
author.feed.ping_hubs
return self
end
# A better name would be very welcome.
def self.find_by_case_insensitive_username(username)
User.first(:username => /^#{Regexp.escape(username)}$/i)
end
def token_expired?
self.perishable_token_set.to_time < 2.days.ago
end
def to_param
username
end
private
def create_feed
f = Feed.create(
:author => self.author
)
self.author.save
save
end
def no_malformed_username
unless (username =~ /[@!"#$\%&'()*,^~{}|`=:;\\\/\[\]\s?]/).nil? && (username =~ /^[.]/).nil? && (username =~ /[.]$/).nil? && (username =~ /[.]{2,}/).nil?
errors.add(:username, "contains restricted characters. Try sticking to letters, numbers, hyphens and underscores.")
end
end
def email_already_confirmed
return if self.email.blank?
if User.where(:email => self.email,
:email_confirmed => true,
:id.ne => self.id).count > 0
errors.add(:email, "is already taken.")
end
end
end