libertree-model 0.9.11

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 (45) hide show
  1. checksums.yaml +7 -0
  2. data/lib/libertree/compat.rb +29 -0
  3. data/lib/libertree/console.rb +17 -0
  4. data/lib/libertree/db.rb +37 -0
  5. data/lib/libertree/embedder.rb +69 -0
  6. data/lib/libertree/embedding/custom-providers.rb +148 -0
  7. data/lib/libertree/model/account-settings.rb +14 -0
  8. data/lib/libertree/model/account.rb +487 -0
  9. data/lib/libertree/model/chat-message.rb +75 -0
  10. data/lib/libertree/model/comment-like.rb +68 -0
  11. data/lib/libertree/model/comment.rb +164 -0
  12. data/lib/libertree/model/contact-list.rb +81 -0
  13. data/lib/libertree/model/embed-cache.rb +6 -0
  14. data/lib/libertree/model/file.rb +6 -0
  15. data/lib/libertree/model/forest.rb +82 -0
  16. data/lib/libertree/model/group-member.rb +13 -0
  17. data/lib/libertree/model/group.rb +47 -0
  18. data/lib/libertree/model/has-display-text.rb +34 -0
  19. data/lib/libertree/model/has-searchable-text.rb +19 -0
  20. data/lib/libertree/model/ignored-member.rb +13 -0
  21. data/lib/libertree/model/invitation.rb +6 -0
  22. data/lib/libertree/model/is-remote-or-local.rb +30 -0
  23. data/lib/libertree/model/job.rb +93 -0
  24. data/lib/libertree/model/member.rb +222 -0
  25. data/lib/libertree/model/message.rb +154 -0
  26. data/lib/libertree/model/node.rb +22 -0
  27. data/lib/libertree/model/node_affiliation.rb +14 -0
  28. data/lib/libertree/model/node_subscription.rb +23 -0
  29. data/lib/libertree/model/notification.rb +71 -0
  30. data/lib/libertree/model/pool-post.rb +17 -0
  31. data/lib/libertree/model/pool.rb +172 -0
  32. data/lib/libertree/model/post-hidden.rb +21 -0
  33. data/lib/libertree/model/post-like.rb +72 -0
  34. data/lib/libertree/model/post-revision.rb +9 -0
  35. data/lib/libertree/model/post.rb +735 -0
  36. data/lib/libertree/model/profile.rb +25 -0
  37. data/lib/libertree/model/remote-storage-connection.rb +6 -0
  38. data/lib/libertree/model/river.rb +249 -0
  39. data/lib/libertree/model/server.rb +35 -0
  40. data/lib/libertree/model/session-account.rb +10 -0
  41. data/lib/libertree/model/url-expansion.rb +6 -0
  42. data/lib/libertree/model.rb +48 -0
  43. data/lib/libertree/query.rb +167 -0
  44. data/lib/libertree/render.rb +28 -0
  45. metadata +198 -0
@@ -0,0 +1,487 @@
1
+ require 'bcrypt'
2
+ require 'net/ldap'
3
+ require 'securerandom'
4
+ require 'gpgme'
5
+ require 'tmpdir'
6
+
7
+ module Libertree
8
+ module Model
9
+ class Account < Sequel::Model(:accounts)
10
+ class KeyError < StandardError; end
11
+
12
+ def self.set_auth_settings(type, settings)
13
+ @@auth_type = type
14
+ @@auth_settings = settings
15
+ end
16
+
17
+ # These two password methods provide a seamless interface to the BCrypted
18
+ # password. The pseudo-field "password" can be treated like a normal
19
+ # String field for reading and writing.
20
+ def password
21
+ @password ||= BCrypt::Password.new(password_encrypted)
22
+ end
23
+
24
+ def password=( new_password )
25
+ @password = BCrypt::Password.create(new_password)
26
+ self.password_encrypted = @password
27
+ end
28
+
29
+ # Used by Ramaze::Helper::UserHelper.
30
+ # @return [Account] authenticated account, or nil on failure to authenticate
31
+ def self.authenticate(creds)
32
+ if @@auth_type && @@auth_type == :ldap
33
+ return if creds['username'].nil? || creds['password'].nil?
34
+ self.authenticate_ldap(creds['username'].to_s,
35
+ creds['password'].to_s,
36
+ @@auth_settings)
37
+ else
38
+ return if creds['password_reset_code'].nil? && (creds['username'].nil? || creds['password'].nil?)
39
+ self.authenticate_db(creds)
40
+ end
41
+ end
42
+
43
+ def self.authenticate_db(creds)
44
+ if creds['password_reset_code'].to_s
45
+ account = Account.where(%{password_reset_code = ? AND NOW() <= password_reset_expiry},
46
+ creds['password_reset_code'].to_s).first
47
+ if account
48
+ return account
49
+ end
50
+ end
51
+
52
+ account = Account[ username: creds['username'].to_s ]
53
+ if account && account.password == creds['password'].to_s
54
+ account
55
+ end
56
+ end
57
+
58
+ # Authenticates against LDAP. On success returns the matching Libertree
59
+ # account or creates a new one.
60
+ # TODO: override email= and password= methods when LDAP is used
61
+ def self.authenticate_ldap(username, password, settings)
62
+ ldap_connection_settings = {
63
+ :host => settings['connection']['host'],
64
+ :port => settings['connection']['port'],
65
+ :base => settings['connection']['base'],
66
+ :auth => {
67
+ :method => :simple,
68
+ :username => settings['connection']['bind_dn'],
69
+ :password => settings['connection']['password']
70
+ }
71
+ }
72
+
73
+ @ldap ||= Net::LDAP.new(ldap_connection_settings)
74
+
75
+ mapping = settings['mapping'] || {
76
+ 'username' => 'uid',
77
+ 'email' => 'mail',
78
+ 'display_name' => 'displayName'
79
+ }
80
+ username.downcase!
81
+ result = @ldap.bind_as(:filter => "(#{mapping['username']}=#{username})",
82
+ :password => password)
83
+
84
+ if result
85
+ account = self[ username: username ]
86
+
87
+ if account
88
+ # update email address and password
89
+ account.email = result.first[mapping['email']].first
90
+ account.password_encrypted = BCrypt::Password.create( password )
91
+ account.save
92
+ else
93
+ # No Libertree account exists for this authenticated LDAP
94
+ # user; create a new one. We will set up the password even
95
+ # though it won't be used while LDAP authentication is
96
+ # enabled to make sure that the account will be protected if
97
+ # LDAP authentication is ever disabled.
98
+ account = self.create( username: username,
99
+ password_encrypted: BCrypt::Password.create( password ),
100
+ email: result.first[mapping['email']].first )
101
+ end
102
+
103
+ account.member.profile.name_display = result.first[mapping['display_name']].first
104
+ account.member.profile.save
105
+ account
106
+ end
107
+ end
108
+
109
+ def member
110
+ @member ||= Member[ account_id: self.id ]
111
+ end
112
+
113
+ def settings
114
+ @settings ||= AccountSettings[ account_id: self.id ]
115
+ end
116
+
117
+ def notify_about(data)
118
+ Notification.create(
119
+ account_id: self.id,
120
+ data: data.to_json
121
+ )
122
+ end
123
+
124
+ def notifications( limit = 128 )
125
+ @notifications ||= Notification.where(account_id: self.id).reverse_order(:id).limit(limit.to_i).all
126
+ end
127
+
128
+ def notifications_unseen
129
+ @notifications_unseen ||= Notification.where(account_id: self.id, seen: false).order(:id)
130
+ end
131
+
132
+ # TODO: Is this no longer used?
133
+ def notifications_unseen_grouped(max_groups=5, limit=200)
134
+ grouped = {}
135
+ keys = [] # so we have a display order
136
+
137
+ notifs = self.notifications_unseen.reverse_order(:id).limit(limit)
138
+ notifs.each do |n|
139
+ next if n.subject.nil?
140
+
141
+ target = case n.subject
142
+ when Libertree::Model::Comment, Libertree::Model::PostLike
143
+ n.subject.post
144
+ when Libertree::Model::CommentLike
145
+ n.subject.comment
146
+ when Libertree::Model::Post
147
+ # mention or group post
148
+ post = n.subject
149
+ post.group || post
150
+ else
151
+ n.subject
152
+ end
153
+
154
+ # collect by target and type; we don't want to put comment
155
+ # and post like notifs in the same bin
156
+ key = [target, n.subject.class]
157
+ if grouped[key]
158
+ grouped[key] << n
159
+ else
160
+ grouped[key] = [n]
161
+ keys << key
162
+ end
163
+ end
164
+
165
+ # get the groups in order of keys
166
+ keys.take(max_groups).map {|k| grouped[k] }
167
+ end
168
+
169
+ def num_notifications_unseen
170
+ @num_notifications_unseen ||= Notification.where(account_id: self.id, seen: false).count
171
+ end
172
+
173
+ def num_chat_unseen
174
+ ChatMessage.where(
175
+ to_member_id: self.member.id,
176
+ seen: false
177
+ ).exclude(
178
+ from_member_id: self.ignored_members.map(&:id)
179
+ ).count
180
+ end
181
+
182
+ def num_chat_unseen_from_partner(member)
183
+ ChatMessage.where(
184
+ from_member_id: member.id,
185
+ to_member_id: self.member.id,
186
+ seen: false
187
+ ).exclude(
188
+ from_member_id: self.ignored_members.map(&:id)
189
+ ).count
190
+ end
191
+
192
+ def chat_messages_unseen
193
+ ChatMessage.where(
194
+ to_member_id: self.member.id,
195
+ seen: false
196
+ ).exclude(
197
+ from_member_id: self.ignored_members.map(&:id)
198
+ ).all
199
+ end
200
+
201
+ def chat_partners_current
202
+ Member.s_wrap(
203
+ %{
204
+ (
205
+ SELECT
206
+ DISTINCT m.*
207
+ , EXISTS(
208
+ SELECT 1
209
+ FROM chat_messages cm2
210
+ WHERE
211
+ cm2.from_member_id = cm.from_member_id
212
+ AND cm2.to_member_id = cm.to_member_id
213
+ AND cm2.seen = FALSE
214
+ ) AS has_unseen_from_other
215
+ FROM
216
+ chat_messages cm
217
+ , members m
218
+ WHERE
219
+ cm.to_member_id = ?
220
+ AND (
221
+ cm.seen = FALSE
222
+ OR cm.time_created > NOW() - '1 hour'::INTERVAL
223
+ )
224
+ AND m.id = cm.from_member_id
225
+ AND NOT EXISTS(
226
+ SELECT 1
227
+ FROM ignored_members im
228
+ WHERE im.member_id = cm.from_member_id
229
+ )
230
+ ) UNION (
231
+ SELECT
232
+ DISTINCT m.*
233
+ , EXISTS(
234
+ SELECT 1
235
+ FROM chat_messages cm2
236
+ WHERE
237
+ cm2.from_member_id = cm.to_member_id
238
+ AND cm2.to_member_id = cm.from_member_id
239
+ AND cm2.seen = FALSE
240
+ ) AS has_unseen_from_other
241
+ FROM
242
+ chat_messages cm
243
+ , members m
244
+ WHERE
245
+ cm.from_member_id = ?
246
+ AND cm.time_created > NOW() - '1 hour'::INTERVAL
247
+ AND m.id = cm.to_member_id
248
+ AND NOT EXISTS(
249
+ SELECT 1
250
+ FROM ignored_members im
251
+ WHERE im.member_id = cm.to_member_id
252
+ )
253
+ )
254
+ },
255
+ self.member.id,
256
+ self.member.id
257
+ )
258
+ end
259
+
260
+ def rivers
261
+ River.s("SELECT * FROM rivers WHERE account_id = ? ORDER BY position ASC, id DESC", self.id)
262
+ end
263
+
264
+ def rivers_not_appended
265
+ rivers.reject(&:appended_to_all)
266
+ end
267
+
268
+ def rivers_appended
269
+ @rivers_appended ||= rivers.find_all(&:appended_to_all)
270
+ end
271
+
272
+ def self.create(*args)
273
+ account = super
274
+ member = Member.create( account_id: account.id )
275
+ AccountSettings.create( account_id: account.id )
276
+ River.create( label: "All posts", query: ":forest", account_id: account.id, home: true )
277
+ account
278
+ end
279
+
280
+ def home_river
281
+ River.where(account_id: self.id, home: true).first
282
+ end
283
+
284
+ def home_river=(river)
285
+ DB.dbh[ "SELECT account_set_home_river(?,?)", self.id, river.id ].get
286
+ end
287
+
288
+ def invitations_not_accepted
289
+ Invitation.where("inviter_account_id = ? AND account_id IS NULL", self.id).order(:id).all
290
+ end
291
+
292
+ def new_invitation
293
+ if invitations_not_accepted.count < 5
294
+ Invitation.create( inviter_account_id: self.id )
295
+ end
296
+ end
297
+
298
+ def generate_api_token
299
+ self.api_token = SecureRandom.hex(16)
300
+ self.save
301
+ end
302
+
303
+ # @param [Time] time The time to compare to
304
+ # @return [Boolean] whether or not the API was last used by this account
305
+ # more recently than the given Time
306
+ def api_last_used_more_recently_than(time)
307
+ self.api_time_last && self.api_time_last.to_time > time
308
+ end
309
+
310
+ # Clears some memoized data
311
+ def dirty
312
+ @notifications = nil
313
+ @notifications_unseen = nil
314
+ @num_notifications_unseen = nil
315
+ @rivers_appended = nil
316
+ @remote_storage_connection = nil
317
+ @settings = nil
318
+ end
319
+
320
+ def admin?
321
+ self.admin
322
+ end
323
+
324
+ def subscribe_to(post)
325
+ DB.dbh[ %{SELECT subscribe_account_to_post(?,?)}, self.id, post.id ].get
326
+ end
327
+
328
+ def unsubscribe_from(post)
329
+ DB.dbh[ "DELETE FROM post_subscriptions WHERE account_id = ? AND post_id = ?", self.id, post.id ].get
330
+ end
331
+
332
+ def subscribed_to?(post)
333
+ DB.dbh[ "SELECT account_subscribed_to_post( ?, ? )", self.id, post.id ].single_value
334
+ end
335
+
336
+ def messages( opts = {} )
337
+ limit = opts.fetch(:limit, 30)
338
+ if opts[:newer]
339
+ time_comparator = '>'
340
+ else
341
+ time_comparator = '<'
342
+ end
343
+ time = Time.at( opts.fetch(:time, Time.now.to_f) ).strftime("%Y-%m-%d %H:%M:%S.%6N%z")
344
+
345
+ ignored_member_ids = self.ignored_members.map(&:id)
346
+ Message.s_wrap(
347
+ %{
348
+ SELECT *
349
+ FROM view__messages_sent_and_received
350
+ WHERE member_id = ?
351
+ AND time_created #{time_comparator} ?
352
+ ORDER BY time_created DESC
353
+ LIMIT #{limit}
354
+ },
355
+ self.member.id, time
356
+ ).reject { |message|
357
+ ignored_member_ids.include?(message.sender_member_id)
358
+ }
359
+
360
+ end
361
+
362
+ # @return [Boolean] true iff password reset was successfully set up
363
+ def self.set_up_password_reset_for(email)
364
+ # TODO: Don't allow registration of accounts with the same email but different case
365
+ account = self.where('LOWER(email) = ?', email.downcase).first
366
+ if account
367
+ account.password_reset_code = SecureRandom.hex(16)
368
+ account.password_reset_expiry = Time.now + 60 * 60
369
+ account.save
370
+ account
371
+ end
372
+ end
373
+
374
+ # NOTE: this method does not save the account record
375
+ def validate_and_set_pubkey(key)
376
+ # import pubkey into temporary keyring to verify it
377
+ GPGME::Engine.home_dir = Dir.tmpdir
378
+ result = GPGME::Key.import key.to_s
379
+
380
+ if result.considered == 1 && result.secret_read == 1
381
+ # Delete the key immediately from the keyring and
382
+ # alert the user in case a secret key was uploaded
383
+ keys = GPGME::Key.find(:secret, result.imports.first.fpr)
384
+ keys.first.delete!(true) # force deletion of secret key
385
+ keys = nil; result = nil
386
+ raise KeyError, 'secret key imported'
387
+ elsif result.considered == 1 && (result.imported == 1 || result.unchanged == 1)
388
+ # We do not check whether the key matches the given email address.
389
+ # This is not necessary, because we don't search the keyring to get
390
+ # the encryption key when sending emails. Instead, we just take
391
+ # whatever key the user provided.
392
+ self.pubkey = key.to_s
393
+ else
394
+ raise KeyError, 'invalid key'
395
+ end
396
+ end
397
+
398
+ def data_hash
399
+ {
400
+ 'account' => {
401
+ 'username' => self.username,
402
+ 'time_created' => self.time_created,
403
+ 'email' => self.email,
404
+ 'custom_css' => self.settings.custom_css,
405
+ 'custom_js' => self.settings.custom_js,
406
+ 'custom_link' => self.settings.custom_link,
407
+ 'font_css' => self.font_css,
408
+ 'excerpt_max_height' => self.settings.excerpt_max_height,
409
+ 'profile' => {
410
+ 'name_display' => self.member.profile.name_display,
411
+ 'description' => self.member.profile.description,
412
+ },
413
+
414
+ 'rivers' => self.rivers.map(&:to_hash),
415
+ 'posts' => self.member.posts(limit: 9999999).map(&:to_hash),
416
+ 'comments' => self.member.comments(9999999).map(&:to_hash),
417
+ 'messages' => self.messages(limit: 9999999).map(&:to_hash),
418
+ }
419
+ }
420
+ end
421
+
422
+ def online?
423
+ Time.now - time_heartbeat.to_time < 5.01 * 60
424
+ end
425
+
426
+ def contact_lists
427
+ ContactList.where(account_id: self.id).all
428
+ end
429
+
430
+ # All contacts, from all contact lists
431
+ # TODO: Can we collect this in SQL instead of mapping, etc. in Ruby?
432
+ def contacts
433
+ contact_lists.map { |list| list.members }.flatten.uniq
434
+ end
435
+
436
+ def contacts_mutual
437
+ self.contacts.find_all { |c|
438
+ c.account && c.account.contacts.include?(self.member)
439
+ }
440
+ end
441
+
442
+ def has_contact_list_by_name_containing_member?(contact_list_name, member)
443
+ DB.dbh[ "SELECT account_has_contact_list_by_name_containing_member( ?, ?, ? )",
444
+ self.id, contact_list_name, member.id ].single_value
445
+ end
446
+
447
+ def delete_cascade
448
+ handle = self.username
449
+ DB.dbh[ "SELECT delete_cascade_account(?)", self.id ].get
450
+
451
+ # distribute deletion of member record
452
+ Libertree::Model::Job.create_for_forests(
453
+ {
454
+ task: 'request:MEMBER-DELETE',
455
+ params: { 'username' => handle, }
456
+ }
457
+ )
458
+ end
459
+
460
+ def remote_storage_connection
461
+ @remote_storage_connection ||= RemoteStorageConnection[ account_id: self.id ]
462
+ end
463
+
464
+ def files
465
+ Libertree::Model::File.s("SELECT * FROM files WHERE account_id = ? ORDER BY id DESC", self.id)
466
+ end
467
+
468
+ def ignored_members
469
+ Libertree::Model::IgnoredMember.where(account_id: self.id).map(&:member)
470
+ end
471
+
472
+ def ignoring?(member)
473
+ self.ignored_members.include? member
474
+ end
475
+
476
+ def ignore_member(member)
477
+ return if member.nil?
478
+ Libertree::Model::IgnoredMember.create(account_id: self.id, member_id: member.id)
479
+ end
480
+
481
+ def unignore_member(member)
482
+ return if member.nil?
483
+ Libertree::Model::IgnoredMember.where(account_id: self.id, member_id: member.id).delete
484
+ end
485
+ end
486
+ end
487
+ end
@@ -0,0 +1,75 @@
1
+ module Libertree
2
+ module Model
3
+ class ChatMessage < Sequel::Model(:chat_messages)
4
+ def after_create
5
+ super
6
+ self.distribute
7
+ end
8
+
9
+ def distribute
10
+ return if ! self.recipient.tree
11
+ Libertree::Model::Job.create(
12
+ {
13
+ task: 'request:CHAT',
14
+ params: {
15
+ 'chat_message_id' => self.id,
16
+ 'server_id' => self.recipient.tree.id,
17
+ }.to_json,
18
+ }
19
+ )
20
+ end
21
+
22
+ def sender
23
+ @sender ||= Member[self.from_member_id]
24
+ end
25
+
26
+ def recipient
27
+ @recipient ||= Member[self.to_member_id]
28
+ end
29
+
30
+ def partner_for(account)
31
+ if account.member == self.sender
32
+ self.recipient
33
+ elsif account.member == self.recipient
34
+ self.sender
35
+ end
36
+ end
37
+
38
+ def self.between(account, member, limit = 32)
39
+ return [] if account.nil? || member.nil?
40
+
41
+ s(
42
+ %{
43
+ SELECT * FROM (
44
+ SELECT
45
+ *
46
+ FROM
47
+ chat_messages cm
48
+ WHERE
49
+ from_member_id = ?
50
+ AND to_member_id = ?
51
+ OR
52
+ from_member_id = ?
53
+ AND to_member_id = ?
54
+ ORDER BY
55
+ time_created DESC
56
+ LIMIT #{ [limit.to_i, 0].max }
57
+ ) AS x
58
+ ORDER BY time_created
59
+ },
60
+ account.member.id,
61
+ member.id,
62
+ member.id,
63
+ account.member.id
64
+ )
65
+ end
66
+
67
+ def self.mark_seen_between(account, member_id)
68
+ return if account.nil?
69
+
70
+ self.where("from_member_id = ? AND to_member_id = ?", member_id.to_i, account.member.id).
71
+ update(seen: true)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,68 @@
1
+ module Libertree
2
+ module Model
3
+ class CommentLike < Sequel::Model(:comment_likes)
4
+ def after_create
5
+ super
6
+ if self.local? && self.comment.post.distribute?
7
+ Libertree::Model::Job.create_for_forests(
8
+ {
9
+ task: 'request:COMMENT-LIKE',
10
+ params: { 'comment_like_id' => self.id, }
11
+ },
12
+ *self.forests
13
+ )
14
+ end
15
+ end
16
+
17
+ def local?
18
+ self.remote_id.nil?
19
+ end
20
+
21
+ def member
22
+ @member ||= Member[self.member_id]
23
+ end
24
+
25
+ def comment
26
+ @comment ||= Comment[self.comment_id]
27
+ end
28
+
29
+ def before_destroy
30
+ if self.local? && self.comment.post.distribute?
31
+ Libertree::Model::Job.create_for_forests(
32
+ {
33
+ task: 'request:COMMENT-LIKE-DELETE',
34
+ params: { 'comment_like_id' => self.id, }
35
+ },
36
+ *self.forests
37
+ )
38
+ end
39
+ super
40
+ end
41
+
42
+ # TODO: the correct method to call is "destroy"
43
+ def delete
44
+ self.before_destroy
45
+ super
46
+ end
47
+
48
+ def delete_cascade
49
+ self.before_destroy
50
+ DB.dbh[ "SELECT delete_cascade_comment_like(?)", self.id ].get
51
+ end
52
+
53
+ def self.create(*args)
54
+ like = super
55
+ like.comment.notify_about_like like
56
+ like
57
+ end
58
+
59
+ def forests
60
+ if self.comment.post.remote?
61
+ self.comment.post.server.forests
62
+ else
63
+ Libertree::Model::Forest.all_local_is_member
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end