Contact
CoCalc Logo Icon
StoreFeaturesDocsShareSupport News AboutSign UpSign In
| Download
Views: 39539
1
###
2
PostgreSQL -- implementation of all the queries needed for the backend servers
3
4
These are all the non-reactive non-push queries, e.g., adding entries to logs,
5
checking on cookies, creating accounts and projects, etc.
6
7
COPYRIGHT : (c) 2017 SageMath, Inc.
8
LICENSE : AGPLv3
9
###
10
11
# limit for async.map or async.paralleLimit, esp. to avoid high concurrency when querying in parallel
12
MAP_LIMIT = 5
13
14
async = require('async')
15
16
random_key = require("random-key")
17
18
misc_node = require('smc-util-node/misc_node')
19
20
{defaults} = misc = require('smc-util/misc')
21
required = defaults.required
22
23
{SCHEMA, DEFAULT_QUOTAS, PROJECT_UPGRADES, COMPUTE_STATES, RECENT_TIMES, RECENT_TIMES_KEY, site_settings_conf} = require('smc-util/schema')
24
25
PROJECT_GROUPS = misc.PROJECT_GROUPS
26
27
28
{PostgreSQL, PROJECT_COLUMNS, one_result, all_results, count_result, expire_time} = require('./postgres')
29
30
class exports.PostgreSQL extends PostgreSQL
31
32
# write an event to the central_log table
33
log: (opts) =>
34
opts = defaults opts,
35
event : required # string
36
value : required # object
37
cb : undefined
38
@_query
39
query : 'INSERT INTO central_log'
40
values :
41
'id::UUID' : misc.uuid()
42
'event::TEXT' : opts.event
43
'value::JSONB' : opts.value
44
'time::TIMESTAMP' : 'NOW()'
45
cb : (err) => opts.cb?(err)
46
47
uncaught_exception: (err) =>
48
# call when things go to hell in some unexpected way; at least
49
# we attempt to record this in the database...
50
try
51
@log
52
event : 'uncaught_exception'
53
value : {error:"#{err}", stack:"#{err.stack}", host:require('os').hostname()}
54
catch e
55
# IT IS CRITICAL THAT uncaught_exception not raise an exception, since if it
56
# did then we would hit a horrible infinite loop!
57
58
# dump a range of data from the central_log table
59
get_log: (opts) =>
60
opts = defaults opts,
61
start : undefined # if not given start at beginning of time
62
end : undefined # if not given include everything until now
63
log : 'central_log' # which table to query
64
event : undefined
65
where : undefined # if given, restrict to records with the given json
66
# containment, e.g., {account_id:'...'}, only returns
67
# entries whose value has the given account_id.
68
cb : required
69
@_query
70
query : "SELECT * FROM #{opts.log}"
71
where :
72
'time >= $::TIMESTAMP' : opts.start
73
'time <= $::TIMESTAMP' : opts.end
74
'event = $::TEXT' : opts.event
75
'value @> $::JSONB' : opts.where
76
cb : all_results(opts.cb)
77
78
# Return every entry x in central_log in the given period of time for
79
# which x.event==event and x.value.account_id == account_id.
80
get_user_log: (opts) =>
81
opts = defaults opts,
82
start : undefined
83
end : undefined # if not given include everything until now
84
event : 'successful_sign_in'
85
account_id : required
86
cb : required
87
@get_log
88
start : opts.start
89
end : opts.end
90
event : opts.event
91
where : {account_id: opts.account_id}
92
cb : opts.cb
93
94
log_client_error: (opts) =>
95
opts = defaults opts,
96
event : 'event'
97
error : 'error'
98
account_id : undefined
99
cb : undefined
100
@_query
101
query : 'INSERT INTO client_error_log'
102
values :
103
'id :: UUID' : misc.uuid()
104
'event :: TEXT' : opts.event
105
'error :: TEXT' : opts.error
106
'account_id :: UUID' : opts.account_id
107
'time :: TIMESTAMP' : 'NOW()'
108
cb : opts.cb
109
110
webapp_error: (opts) =>
111
opts = defaults opts,
112
account_id : undefined
113
name : undefined
114
message : undefined
115
comment : undefined
116
stacktrace : undefined
117
file : undefined
118
path : undefined
119
lineNumber : undefined
120
columnNumber : undefined
121
severity : undefined
122
browser : undefined
123
mobile : undefined
124
responsive : undefined
125
user_agent : undefined
126
smc_version : undefined
127
build_date : undefined
128
smc_git_rev : undefined
129
uptime : undefined
130
start_time : undefined
131
cb : undefined
132
@_query
133
query : 'INSERT INTO webapp_errors'
134
values :
135
'id :: UUID' : misc.uuid()
136
'account_id :: UUID' : opts.account_id
137
'name :: TEXT' : opts.name
138
'message :: TEXT' : opts.message
139
'comment :: TEXT' : opts.comment
140
'stacktrace :: TEXT' : opts.stacktrace
141
'file :: TEXT' : opts.file
142
'path :: TEXT' : opts.path
143
'lineNumber :: INTEGER' : opts.lineNumber
144
'columnNumber :: INTEGER' : opts.columnNumber
145
'severity :: TEXT' : opts.severity
146
'browser :: TEXT' : opts.browser
147
'mobile :: BOOLEAN' : opts.mobile
148
'responsive :: BOOLEAN' : opts.responsive
149
'user_agent :: TEXT' : opts.user_agent
150
'smc_version :: TEXT' : opts.smc_version
151
'build_date :: TEXT' : opts.build_date
152
'smc_git_rev :: TEXT' : opts.smc_git_rev
153
'uptime :: TEXT' : opts.uptime
154
'start_time :: TIMESTAMP' : opts.start_time
155
'time :: TIMESTAMP' : 'NOW()'
156
cb : opts.cb
157
158
get_client_error_log: (opts) =>
159
opts = defaults opts,
160
start : undefined # if not given start at beginning of time
161
end : undefined # if not given include everything until now
162
event : undefined
163
cb : required
164
opts.log = 'client_error_log'
165
@get_log(opts)
166
167
set_server_setting: (opts) =>
168
opts = defaults opts,
169
name : required
170
value : required
171
cb : required
172
@_query
173
query : 'INSERT INTO server_settings'
174
values :
175
'name::TEXT' : opts.name
176
'value::TEXT' : opts.value
177
conflict : 'name'
178
cb : opts.cb
179
180
get_server_setting: (opts) =>
181
opts = defaults opts,
182
name : required
183
cb : required
184
@_query
185
query : 'SELECT value FROM server_settings'
186
where :
187
"name = $::TEXT" : opts.name
188
cb : one_result('value', opts.cb)
189
190
# TODO: optimization -- site_settings could be done as a changefeed (and is done as one in rethink.coffee)
191
get_site_settings: (opts) =>
192
opts = defaults opts,
193
cb : required # (err, settings)
194
@_query
195
query : 'SELECT name, value FROM server_settings'
196
cache : true
197
where :
198
"name = ANY($)" : misc.keys(site_settings_conf)
199
cb : (err, result) =>
200
if err
201
opts.cb(err)
202
else
203
x = {}
204
for k in result.rows
205
if k.name == 'commercial' and k.value in ['true', 'false'] # backward compatibility
206
k.value = eval(k.value)
207
x[k.name] = k.value
208
opts.cb(undefined, x)
209
210
set_passport_settings: (opts) =>
211
opts = defaults opts,
212
strategy : required
213
conf : required
214
cb : required
215
@_query
216
query : 'INSERT into passport_settings'
217
values :
218
'strategy::TEXT ' : opts.strategy
219
'conf ::JSONB' : opts.conf
220
conflict : 'strategy'
221
cb : opts.cb
222
223
get_passport_settings: (opts) =>
224
opts = defaults opts,
225
strategy : required
226
cb : required
227
@_query
228
query : 'SELECT conf FROM passport_settings'
229
where :
230
"strategy = $::TEXT" : opts.strategy
231
cb : one_result('conf', opts.cb)
232
233
###
234
API Key Management
235
###
236
get_api_key: (opts) =>
237
opts = defaults opts,
238
account_id : required
239
cb : required
240
@_query
241
query : 'SELECT api_key FROM accounts'
242
where :
243
"account_id = $::UUID" : opts.account_id
244
cb : one_result (err, x) =>
245
opts.cb(err, x?.api_key ? '')
246
247
get_account_with_api_key: (opts) =>
248
opts = defaults opts,
249
api_key : required
250
cb : required # cb(err, account_id)
251
@_query
252
query : 'SELECT account_id FROM accounts'
253
where :
254
"api_key = $::TEXT" : opts.api_key
255
cb : one_result (err, x) =>
256
opts.cb(err, x?.account_id)
257
258
delete_api_key: (opts) =>
259
opts = defaults opts,
260
account_id : required
261
cb : required
262
@_query
263
query : 'UPDATE accounts SET api_key = NULL'
264
where :
265
"account_id = $::UUID" : opts.account_id
266
cb : opts.cb
267
268
regenerate_api_key: (opts) =>
269
opts = defaults opts,
270
account_id : required
271
cb : required
272
api_key = 'sk_' + random_key.generate(24)
273
@_query
274
query : 'UPDATE accounts'
275
set : {api_key : api_key}
276
where :
277
"account_id = $::UUID" : opts.account_id
278
cb : (err) =>
279
opts.cb(err, api_key)
280
281
###
282
Account creation, deletion, existence
283
###
284
create_account: (opts={}) ->
285
opts = defaults opts,
286
first_name : required
287
last_name : required
288
289
created_by : undefined # ip address of computer creating this account
290
291
email_address : undefined
292
password_hash : undefined
293
294
passport_strategy : undefined
295
passport_id : undefined
296
passport_profile : undefined
297
cb : required # cb(err, account_id)
298
299
dbg = @_dbg("create_account(#{opts.first_name}, #{opts.last_name} #{opts.email_address}, #{opts.passport_strategy}, #{opts.passport_id})")
300
dbg()
301
302
if opts.email_address? # canonicalize the email address, if given
303
opts.email_address = misc.lower_email_address(opts.email_address)
304
305
if not opts.email_address? and not opts.passport_strategy?
306
opts.cb("email_address or passport must be given")
307
return
308
309
account_id = misc.uuid()
310
311
passport_key = undefined
312
if opts.passport_strategy?
313
# This is to make it impossible to accidentally create two accounts with the same passport
314
# due to calling create_account twice at once. See TODO below about changing schema.
315
# This should be enough for now since a given user only makes their account through a single
316
# server via the persistent websocket...
317
@_create_account_passport_keys ?= {}
318
passport_key = @_passport_key(strategy:opts.passport_strategy, id:opts.passport_id)
319
last = @_create_account_passport_keys[passport_key]
320
if last? and new Date() - last <= 60*1000
321
opts.cb("recent attempt to make account with this passport strategy")
322
return
323
@_create_account_passport_keys[passport_key] = new Date()
324
325
async.series([
326
(cb) =>
327
if not opts.passport_strategy?
328
cb(); return
329
dbg("verify that no account with passport (strategy='#{opts.passport_strategy}', id='#{opts.passport_id}') already exists")
330
# **TODO:** need to make it so insertion into the table still would yield an error due to
331
# unique constraint; this will require probably moving the passports
332
# object to a separate table. This is important, since this is exactly the place where
333
# a race condition might cause touble!
334
@passport_exists
335
strategy : opts.passport_strategy
336
id : opts.passport_id
337
cb : (err, account_id) ->
338
if err
339
cb(err)
340
else if account_id
341
cb("account with email passport strategy '#{opts.passport_strategy}' and id '#{opts.passport_id}' already exists")
342
else
343
cb()
344
(cb) =>
345
dbg("create the actual account")
346
@_query
347
query : "INSERT INTO accounts"
348
values :
349
'account_id :: UUID' : account_id
350
'first_name :: TEXT' : opts.first_name
351
'last_name :: TEXT' : opts.last_name
352
'created :: TIMESTAMP' : new Date()
353
'created_by :: INET' : opts.created_by
354
'password_hash :: CHAR(173)' : opts.password_hash
355
'email_address :: TEXT' : opts.email_address
356
cb : cb
357
(cb) =>
358
if opts.passport_strategy?
359
dbg("add passport authentication strategy")
360
@create_passport
361
account_id : account_id
362
strategy : opts.passport_strategy
363
id : opts.passport_id
364
profile : opts.passport_profile
365
cb : cb
366
else
367
cb()
368
], (err) =>
369
if err
370
dbg("error creating account -- #{err}")
371
opts.cb(err)
372
else
373
dbg("successfully created account")
374
opts.cb(undefined, account_id)
375
)
376
377
is_admin: (opts) =>
378
opts = defaults opts,
379
account_id : required
380
cb : required
381
@_query
382
query : "SELECT groups FROM accounts"
383
where : 'account_id = $::UUID':opts.account_id
384
cache : true
385
cb : one_result 'groups', (err, groups) =>
386
opts.cb(err, groups? and 'admin' in groups)
387
388
make_user_admin: (opts) =>
389
opts = defaults opts,
390
account_id : undefined
391
email_address : undefined
392
cb : required
393
if not opts.account_id? and not opts.email_address?
394
opts.cb?("account_id or email_address must be given")
395
return
396
async.series([
397
(cb) =>
398
if opts.account_id?
399
cb()
400
else
401
@get_account
402
email_address : opts.email_address
403
columns : ['account_id']
404
cb : (err, x) =>
405
if err
406
cb(err)
407
else if not x?
408
cb("no such email address")
409
else
410
opts.account_id = x.account_id
411
cb()
412
(cb) =>
413
@clear_cache() # caching is mostly for permissions so this is exactly when it would be nice to clear it.
414
@_query
415
query : "UPDATE accounts"
416
where : 'account_id = $::UUID':opts.account_id
417
set :
418
groups : ['admin']
419
cb : cb
420
], opts.cb)
421
422
count_accounts_created_by: (opts) =>
423
opts = defaults opts,
424
ip_address : required
425
age_s : required
426
cb : required
427
@_count
428
table : 'accounts'
429
where :
430
"created_by = $::INET" : opts.ip_address
431
"created >= $::TIMESTAMP" : misc.seconds_ago(opts.age_s)
432
cb : opts.cb
433
434
# Completely delete the given account from the database. This doesn't
435
# do any sort of cleanup of things associated with the account! There
436
# is no reason to ever use this, except for testing purposes.
437
delete_account: (opts) =>
438
opts = defaults opts,
439
account_id : required
440
cb : required
441
if not @_validate_opts(opts) then return
442
@_query
443
query : "DELETE FROM accounts"
444
where : "account_id = $::UUID" : opts.account_id
445
cb : opts.cb
446
447
# Mark the account as deleted, thus freeing up the email
448
# address for use by another account, etc. The actual
449
# account entry remains in the database, since it may be
450
# referred to by many other things (projects, logs, etc.).
451
# However, the deleted field is set to true, so the account
452
# is excluded from user search.
453
mark_account_deleted: (opts) =>
454
opts = defaults opts,
455
account_id : undefined
456
email_address : undefined
457
cb : required
458
if not opts.account_id? and not opts.email_address?
459
opts.cb("one of email address or account_id must be specified")
460
return
461
462
query = undefined
463
email_address = undefined
464
async.series([
465
(cb) =>
466
if opts.account_id?
467
cb()
468
else
469
@account_exists
470
email_address : opts.email_address
471
cb : (err, account_id) =>
472
if err
473
cb(err)
474
else if not account_id
475
cb("no such email address known")
476
else
477
opts.account_id = account_id
478
cb()
479
(cb) =>
480
@_query
481
query : "SELECT email_address FROM accounts"
482
where : "account_id = $::UUID" : opts.account_id
483
cb : one_result 'email_address', (err, x) =>
484
email_address = x; cb(err)
485
(cb) =>
486
@_query
487
query : "UPDATE accounts"
488
set :
489
"deleted::BOOLEAN" : true
490
"email_address_before_delete::TEXT" : email_address
491
"email_address" : null
492
"passports" : null
493
where : "account_id = $::UUID" : opts.account_id
494
cb : cb
495
], opts.cb)
496
497
account_exists: (opts) =>
498
opts = defaults opts,
499
email_address : required
500
cb : required # cb(err, account_id or undefined) -- actual account_id if it exists; err = problem with db connection...
501
@_query
502
query : 'SELECT account_id FROM accounts'
503
where : "email_address = $::TEXT" : opts.email_address
504
cb : one_result('account_id', opts.cb)
505
506
# set an account creation action, or return all of them for the given email address
507
account_creation_actions: (opts) =>
508
opts = defaults opts,
509
email_address : required
510
action : undefined # if given, adds this action; if not, returns all non-expired actions
511
ttl : 60*60*24*14 # add action with this ttl in seconds (default: 2 weeks)
512
cb : required # if ttl not given cb(err, [array of actions])
513
if opts.action?
514
# add action
515
@_query
516
query : 'INSERT INTO account_creation_actions'
517
values :
518
'id :: UUID' : misc.uuid()
519
'email_address :: TEXT' : opts.email_address
520
'action :: JSONB' : opts.action
521
'expire :: TIMESTAMP' : expire_time(opts.ttl)
522
cb : opts.cb
523
else
524
# query for actions
525
@_query
526
query : 'SELECT action FROM account_creation_actions'
527
where :
528
'email_address = $::TEXT' : opts.email_address
529
'expire >= $::TIMESTAMP' : new Date()
530
cb : all_results('action', opts.cb)
531
532
account_creation_actions_success: (opts) =>
533
opts = defaults opts,
534
account_id : required
535
cb : required
536
@_query
537
query : 'UPDATE accounts'
538
set :
539
'creation_actions_done::BOOLEAN' : true
540
where :
541
'account_id = $::UUID' : opts.account_id
542
cb : opts.cb
543
544
do_account_creation_actions: (opts) =>
545
opts = defaults opts,
546
email_address : required
547
account_id : required
548
cb : required
549
dbg = @_dbg("do_account_creation_actions(email_address='#{opts.email_address}')")
550
@account_creation_actions
551
email_address : opts.email_address
552
cb : (err, actions) =>
553
if err
554
opts.cb(err); return
555
f = (action, cb) =>
556
dbg("account_creation_actions: action = #{misc.to_json(action)}")
557
if action.action == 'add_to_project'
558
@add_user_to_project
559
project_id : action.project_id
560
account_id : opts.account_id
561
group : action.group
562
cb : (err) =>
563
if err
564
dbg("Error adding user to project: #{err}")
565
cb(err)
566
else
567
dbg("ERROR: skipping unknown action -- #{action.action}")
568
# also store in database so we can look into this later.
569
@log
570
event : 'unknown_action'
571
value :
572
error : "unknown_action"
573
action : action
574
account_id : opts.account_id
575
host : require('os').hostname()
576
cb()
577
async.map actions, f, (err) =>
578
if not err
579
@account_creation_actions_success
580
account_id : opts.account_id
581
cb : opts.cb
582
else
583
opts.cb(err)
584
585
###
586
Stripe support for accounts
587
###
588
# Set the stripe id in our database of this user. If there is no user with this
589
# account_id, then this is a NO-OP.
590
set_stripe_customer_id: (opts) =>
591
opts = defaults opts,
592
account_id : required
593
customer_id : required
594
cb : required
595
@_query
596
query : 'UPDATE accounts'
597
set : 'stripe_customer_id::TEXT' : opts.customer_id
598
where : 'account_id = $::UUID' : opts.account_id
599
cb : opts.cb
600
601
# Get the stripe id in our database of this user (or undefined if not stripe_id or no such user).
602
get_stripe_customer_id: (opts) =>
603
opts = defaults opts,
604
account_id : required
605
cb : required
606
@_query
607
query : 'SELECT stripe_customer_id FROM accounts'
608
where : 'account_id = $::UUID' : opts.account_id
609
cb : one_result('stripe_customer_id', opts.cb)
610
611
###
612
Stripe integration/sync:
613
Get all info about the given account from stripe and put it in our own local database.
614
Call it with force right after the user does some action that will change their
615
account info status. This will never touch stripe if the user doesn't have
616
a stripe_customer_id. TODO: This should be replaced by webhooks...
617
###
618
stripe_update_customer: (opts) =>
619
opts = defaults opts,
620
account_id : required # user's account_id
621
stripe : undefined # api connection to stripe
622
customer_id : undefined # will be looked up if not known
623
cb : undefined
624
customer = undefined
625
dbg = @_dbg("stripe_update_customer(account_id='#{opts.account_id}')")
626
async.series([
627
(cb) =>
628
if opts.customer_id?
629
cb(); return
630
dbg("get_stripe_customer_id")
631
@get_stripe_customer_id
632
account_id : opts.account_id
633
cb : (err, x) =>
634
dbg("their stripe id is #{x}")
635
opts.customer_id = x; cb(err)
636
(cb) =>
637
if opts.customer_id? and not opts.stripe?
638
@get_server_setting
639
name : 'stripe_secret_key'
640
cb : (err, secret) =>
641
if err
642
cb(err)
643
else if not secret
644
cb("stripe must be configured")
645
else
646
opts.stripe = require("stripe")(secret)
647
cb()
648
else
649
cb()
650
(cb) =>
651
if opts.customer_id?
652
opts.stripe.customers.retrieve opts.customer_id, (err, x) =>
653
dbg("got stripe info -- #{err}")
654
customer = x; cb(err)
655
else
656
cb()
657
(cb) =>
658
if opts.customer_id?
659
@_query
660
query : 'UPDATE accounts'
661
set : 'stripe_customer::JSONB' : customer
662
where : 'account_id = $::UUID' : opts.account_id
663
cb : opts.cb
664
else
665
cb()
666
], opts.cb)
667
668
###
669
Querying for searchable information about accounts.
670
###
671
account_ids_to_usernames: (opts) =>
672
opts = defaults opts,
673
account_ids : required
674
cb : required # (err, mapping {account_id:{first_name:?, last_name:?}})
675
if not @_validate_opts(opts) then return
676
if opts.account_ids.length == 0 # easy special case -- don't waste time on a db query
677
opts.cb(undefined, [])
678
return
679
@_query
680
query : 'SELECT account_id, first_name, last_name FROM accounts'
681
where : 'account_id = ANY($::UUID[])' : opts.account_ids
682
cb : (err, result) =>
683
if err
684
opts.cb(err)
685
else
686
v = misc.dict(([r.account_id, {first_name:r.first_name, last_name:r.last_name}] for r in result.rows))
687
# fill in unknown users (should never be hit...)
688
for id in opts.account_ids
689
if not v[id]?
690
v[id] = {first_name:undefined, last_name:undefined}
691
opts.cb(err, v)
692
693
get_usernames: (opts) =>
694
opts = defaults opts,
695
account_ids : required
696
use_cache : true
697
cache_time_s : 60*60 # one hour
698
cb : required # cb(err, map from account_id to object (user name))
699
if not @_validate_opts(opts) then return
700
usernames = {}
701
for account_id in opts.account_ids
702
usernames[account_id] = false
703
if opts.use_cache
704
if not @_account_username_cache?
705
@_account_username_cache = {}
706
for account_id, done of usernames
707
if not done and @_account_username_cache[account_id]?
708
usernames[account_id] = @_account_username_cache[account_id]
709
@account_ids_to_usernames
710
account_ids : (account_id for account_id,done of usernames when not done)
711
cb : (err, results) =>
712
if err
713
opts.cb(err)
714
else
715
# use a closure so that the cache clear timeout below works
716
# with the correct account_id!
717
f = (account_id, username) =>
718
usernames[account_id] = username
719
@_account_username_cache[account_id] = username
720
setTimeout((()=>delete @_account_username_cache[account_id]),
721
1000*opts.cache_time_s)
722
for account_id, username of results
723
f(account_id, username)
724
opts.cb(undefined, usernames)
725
726
user_search: (opts) =>
727
opts = defaults opts,
728
query : required # comma separated list of email addresses or strings such as 'foo bar' (find everything where foo and bar are in the name)
729
limit : 50 # limit on string queries; email query always returns 0 or 1 result per email address
730
cb : required # cb(err, list of {id:?, first_name:?, last_name:?, email_address:?}), where the
731
# email_address *only* occurs in search queries that are by email_address -- we do not reveal
732
# email addresses of users queried by name.
733
{string_queries, email_queries} = misc.parse_user_search(opts.query)
734
results = []
735
dbg = @_dbg("user_search")
736
dbg("query = #{misc.to_json(opts.query)}")
737
async.parallel([
738
(cb) =>
739
if email_queries.length == 0
740
cb(); return
741
dbg("do email queries -- with exactly two targeted db queries (even if there are hundreds of addresses)")
742
@_query
743
query : 'SELECT account_id, first_name, last_name, email_address FROM accounts'
744
where : 'email_address = ANY($::TEXT[])' : email_queries
745
cb : all_results (err, rows) =>
746
cb(err, if rows? then results.push(rows...))
747
(cb) =>
748
dbg("do all string queries")
749
if string_queries.length == 0 or (opts.limit? and results.length >= opts.limit)
750
# nothing to do
751
cb(); return
752
# substring search on first and last name.
753
# With the two indexes, the query below is instant even on several
754
# hundred thousand accounts:
755
# CREATE INDEX accounts_first_name_idx ON accounts(first_name text_pattern_ops);
756
# CREATE INDEX accounts_last_name_idx ON accounts(last_name text_pattern_ops);
757
where = []
758
params = []
759
i = 1
760
for terms in string_queries
761
v = []
762
for s in terms
763
s = s.toLowerCase()
764
v.push("(lower(first_name) LIKE $#{i}::TEXT OR lower(last_name) LIKE $#{i}::TEXT)")
765
params.push("#{s}%") # require string to name to start with string -- makes searching way faster and is more useful too
766
i += 1
767
where.push("(#{v.join(' AND ')})")
768
query = 'SELECT account_id, first_name, last_name FROM accounts'
769
query += " WHERE deleted IS NOT TRUE AND (#{where.join(' OR ')})"
770
query += " LIMIT $#{i}::INTEGER"; i += 1
771
params.push(opts.limit)
772
@_query
773
query : query
774
params : params
775
cb : all_results (err, rows) =>
776
cb(err, if rows? then results.push(rows...))
777
], (err) => opts.cb(err, results))
778
779
_account_where: (opts) =>
780
if opts.account_id?
781
return {"account_id = $::UUID" : opts.account_id}
782
else
783
return {"email_address = $::TEXT" : opts.email_address}
784
785
get_account: (opts) =>
786
opts = defaults opts,
787
email_address : undefined # provide either email or account_id (not both)
788
account_id : undefined
789
columns : ['account_id',
790
'password_hash',
791
'password_is_set', # true or false, depending on whether a password is set (since don't send password_hash to user!)
792
'first_name', 'last_name',
793
'email_address',
794
'evaluate_key', 'autosave', 'terminal', 'editor_settings', 'other_settings',
795
'groups',
796
'passports'
797
]
798
cb : required
799
if not @_validate_opts(opts) then return
800
columns = misc.copy(opts.columns)
801
if 'password_is_set' in columns
802
if 'password_hash' not in columns
803
remove_password_hash = true
804
columns.push('password_hash')
805
misc.remove(columns, 'password_is_set')
806
password_is_set = true
807
@_query
808
query : "SELECT #{columns.join(',')} FROM accounts"
809
where : @_account_where(opts)
810
cb : one_result (err, z) =>
811
if err
812
opts.cb(err)
813
else if not z?
814
opts.cb("no such account")
815
else
816
if password_is_set
817
z.password_is_set = !!z.password_hash
818
if remove_password_hash
819
delete z.password_hash
820
for c in columns
821
if not z[c]? # for same semantics as rethinkdb... (for now)
822
delete z[c]
823
opts.cb(undefined, z)
824
825
# check whether or not a user is banned
826
is_banned_user: (opts) =>
827
opts = defaults opts,
828
email_address : undefined
829
account_id : undefined
830
cb : required # cb(err, true if banned; false if not banned)
831
if not @_validate_opts(opts) then return
832
@_query
833
query : 'SELECT banned FROM accounts'
834
where : @_account_where(opts)
835
cb : one_result('banned', (err, banned) => opts.cb(err, !!banned))
836
837
_set_ban_user: (opts) =>
838
opts = defaults opts,
839
account_id : undefined
840
email_address : undefined
841
banned : required
842
cb : required
843
if not @_validate_opts(opts) then return
844
@_query
845
query : 'UPDATE accounts'
846
set : {banned: opts.banned}
847
where : @_account_where(opts)
848
cb : one_result('banned', opts.cb)
849
850
ban_user: (opts) =>
851
@_set_ban_user(misc.merge(opts, banned:true))
852
853
unban_user: (opts) =>
854
@_set_ban_user(misc.merge(opts, banned:false))
855
856
###
857
Passports -- accounts linked to Google/Dropbox/Facebook/Github, etc.
858
The Schema is slightly redundant, but indexed properly:
859
{passports:['google-id', 'facebook-id'], passport_profiles:{'google-id':'...', 'facebook-id':'...'}}
860
###
861
_passport_key: (opts) => "#{opts.strategy}-#{opts.id}"
862
863
create_passport: (opts) =>
864
opts= defaults opts,
865
account_id : required
866
strategy : required
867
id : required
868
profile : required
869
cb : required # cb(err)
870
@_dbg('create_passport')(misc.to_json(opts.profile))
871
@_query
872
query : "UPDATE accounts"
873
jsonb_set :
874
passports : "#{@_passport_key(opts)}" : opts.profile
875
where :
876
"account_id = $::UUID" : opts.account_id
877
cb : opts.cb
878
879
delete_passport: (opts) =>
880
opts= defaults opts,
881
account_id : required
882
strategy : required
883
id : required
884
cb : required
885
@_dbg('delete_passport')(misc.to_json(opts.profile))
886
@_query
887
query : "UPDATE accounts"
888
jsonb_set :
889
passports : "#{@_passport_key(opts)}" : null # delete it
890
where :
891
"account_id = $::UUID" : opts.account_id
892
cb : opts.cb
893
894
passport_exists: (opts) =>
895
opts = defaults opts,
896
strategy : required
897
id : required
898
cb : required # cb(err, account_id or undefined)
899
@_query
900
query : "SELECT account_id FROM accounts"
901
where : "(passports->>$::TEXT) IS NOT NULL" : @_passport_key(opts)
902
cb : (err, result) =>
903
opts.cb(err, result?.rows[0]?.account_id)
904
905
_touch_account: (account_id, cb) =>
906
if @_throttle('_touch_account', 120, account_id)
907
cb()
908
return
909
@_query
910
query : 'UPDATE accounts'
911
set : {last_active: 'NOW()'}
912
where : "account_id = $::UUID" : account_id
913
cb : cb
914
915
_touch_project: (project_id, account_id, cb) =>
916
if @_throttle('_user_touch_project', 60, project_id, account_id)
917
cb()
918
return
919
NOW = new Date()
920
@_query
921
query : "UPDATE projects"
922
set : {last_edited : NOW}
923
jsonb_merge : {last_active:{"#{account_id}":NOW}}
924
where : "project_id = $::UUID" : project_id
925
cb : cb
926
927
# Indicate activity by a user, possibly on a specific project, and
928
# then possibly on a specific path in that project.
929
touch: (opts) =>
930
opts = defaults opts,
931
account_id : required
932
project_id : undefined
933
path : undefined
934
action : 'edit'
935
ttl_s : 50 # min activity interval; calling this function with same input again within this interval is ignored
936
cb : undefined
937
if opts.ttl_s
938
if @_throttle('touch', opts.ttl_s, opts.account_id, opts.project_id, opts.path, opts.action)
939
opts.cb?()
940
return
941
942
now = new Date()
943
async.parallel([
944
(cb) =>
945
@_touch_account(opts.account_id, cb)
946
(cb) =>
947
if not opts.project_id?
948
cb(); return
949
@_touch_project(opts.project_id, opts.account_id, cb)
950
(cb) =>
951
if not opts.path? or not opts.project_id?
952
cb(); return
953
@record_file_use(project_id:opts.project_id, path:opts.path, action:opts.action, account_id:opts.account_id, cb:cb)
954
], (err)->opts.cb?(err))
955
956
###
957
Rememberme cookie functionality
958
###
959
# Save remember me info in the database
960
save_remember_me: (opts) =>
961
opts = defaults opts,
962
account_id : required
963
hash : required
964
value : required
965
ttl : required
966
cb : required
967
if not @_validate_opts(opts) then return
968
@_query
969
query : 'INSERT INTO remember_me'
970
values :
971
'hash :: TEXT ' : opts.hash.slice(0,127)
972
'value :: JSONB ' : opts.value
973
'expire :: TIMESTAMP ' : expire_time(opts.ttl)
974
'account_id :: UUID ' : opts.account_id
975
conflict : 'hash'
976
cb : opts.cb
977
978
# Invalidate all outstanding remember me cookies for the given account by
979
# deleting them from the remember_me key:value store.
980
invalidate_all_remember_me: (opts) =>
981
opts = defaults opts,
982
account_id : required
983
cb : undefined
984
@_query
985
query : 'DELETE FROM remember_me'
986
where :
987
'account_id = $::UUID' : opts.account_id
988
cb : opts.cb
989
990
# Get remember me cookie with given hash. If it has expired,
991
# get back undefined instead. (Actually deleting expired)
992
get_remember_me: (opts) =>
993
opts = defaults opts,
994
hash : required
995
cb : required # cb(err, signed_in_message)
996
@_query
997
query : 'SELECT value, expire FROM remember_me'
998
where :
999
'hash = $::TEXT' : opts.hash.slice(0,127)
1000
cb : one_result('value', opts.cb)
1001
1002
delete_remember_me: (opts) =>
1003
opts = defaults opts,
1004
hash : required
1005
cb : undefined
1006
@_query
1007
query : 'DELETE FROM remember_me'
1008
where :
1009
'hash = $::TEXT' : opts.hash.slice(0,127)
1010
cb : opts.cb
1011
1012
###
1013
# Changing password/email, etc. sensitive info about a user
1014
###
1015
1016
# Change the password for the given account.
1017
change_password: (opts={}) =>
1018
opts = defaults opts,
1019
account_id : required
1020
password_hash : required
1021
invalidate_remember_me : true
1022
cb : required
1023
if not @_validate_opts(opts) then return
1024
if opts.password_hash.length > 173
1025
opts.cb("password_hash must be at most 173 characters")
1026
return
1027
async.series([ # don't do in parallel -- don't kill remember_me if password failed!
1028
(cb) =>
1029
@_query
1030
query : 'UPDATE accounts'
1031
set : {password_hash : opts.password_hash}
1032
where : @_account_where(opts)
1033
cb : cb
1034
(cb) =>
1035
if opts.invalidate_remember_me
1036
@invalidate_all_remember_me
1037
account_id : opts.account_id
1038
cb : cb
1039
else
1040
cb()
1041
], opts.cb)
1042
1043
# Reset Password MEANT FOR INTERACTIVE USE -- if password is not given, will prompt for it.
1044
reset_password: (opts) =>
1045
opts = defaults opts,
1046
email_address : undefined
1047
account_id : undefined
1048
password : undefined
1049
random : true # if true (the default), will generate and print a random password.
1050
cb : undefined
1051
dbg = @_dbg("reset_password")
1052
read = require('read')
1053
async.series([
1054
(cb) =>
1055
if opts.account_id?
1056
cb()
1057
return
1058
@get_account
1059
email_address : opts.email_address
1060
columns : ['account_id']
1061
cb : (err, data) =>
1062
opts.account_id = data?.account_id
1063
cb(err)
1064
(cb) =>
1065
if opts.password?
1066
cb()
1067
return
1068
if opts.random
1069
require('crypto').randomBytes 16, (err, buffer) =>
1070
opts.password = buffer.toString('hex')
1071
cb()
1072
return
1073
read {prompt:'Password: ', silent:true}, (err, passwd) =>
1074
opts.passwd0 = passwd; cb(err)
1075
(cb) =>
1076
if opts.password?
1077
cb()
1078
return
1079
read {prompt:'Retype password: ', silent:true}, (err, passwd1) =>
1080
if err
1081
cb(err)
1082
else
1083
if passwd1 != opts.passwd0
1084
cb("Passwords do not match.")
1085
else
1086
opts.password = passwd1
1087
cb()
1088
(cb) =>
1089
# change the user's password in the database.
1090
@change_password
1091
account_id : opts.account_id
1092
password_hash : require('./auth').password_hash(opts.password)
1093
cb : cb
1094
], (err) =>
1095
if err
1096
console.warn("Error -- #{err}")
1097
else
1098
console.log("Password changed for #{opts.email_address}")
1099
if opts.random
1100
console.log("Random Password:\n\n\t\t#{opts.password}\n\n")
1101
opts.cb?(err)
1102
)
1103
1104
# Change the email address, unless the email_address we're changing to is already taken.
1105
change_email_address: (opts={}) =>
1106
opts = defaults opts,
1107
account_id : required
1108
email_address : required
1109
cb : required
1110
if not @_validate_opts(opts) then return
1111
@account_exists
1112
email_address : opts.email_address
1113
cb : (err, exists) =>
1114
if err
1115
opts.cb(err)
1116
else if exists
1117
opts.cb("email_already_taken")
1118
else
1119
@_query
1120
query : 'UPDATE accounts'
1121
set : {email_address: opts.email_address}
1122
where : @_account_where(opts)
1123
cb : opts.cb
1124
1125
###
1126
User auth token
1127
###
1128
# save an auth token in the database
1129
save_auth_token: (opts) =>
1130
opts = defaults opts,
1131
account_id : required
1132
auth_token : required
1133
ttl : 12*3600 # ttl in seconds (default: 12 hours)
1134
cb : required
1135
if not @_validate_opts(opts) then return
1136
@_query
1137
query : 'INSERT INTO auth_tokens'
1138
values :
1139
'auth_token :: CHAR(24) ' : opts.auth_token
1140
'expire :: TIMESTAMP ' : expire_time(opts.ttl)
1141
'account_id :: UUID ' : opts.account_id
1142
cb : opts.cb
1143
1144
# Get account_id of account with given auth_token. If it
1145
# is not defined, get back undefined instead.
1146
get_auth_token_account_id: (opts) =>
1147
opts = defaults opts,
1148
auth_token : required
1149
cb : required # cb(err, account_id)
1150
@_query
1151
query : 'SELECT account_id, expire FROM auth_tokens'
1152
where :
1153
'auth_token = $::CHAR(24)' : opts.auth_token
1154
cb : one_result (err, x) =>
1155
if err
1156
opts.cb(err)
1157
else if not x?
1158
opts.cb() # nothing
1159
else if x.expire <= new Date()
1160
opts.cb()
1161
else
1162
opts.cb(undefined, x.account_id)
1163
1164
delete_auth_token: (opts) =>
1165
opts = defaults opts,
1166
auth_token : required
1167
cb : undefined # cb(err)
1168
@_query
1169
query : 'DELETE FROM auth_tokens'
1170
where :
1171
'auth_token = $::CHAR(24)' : opts.auth_token
1172
cb : opts.cb
1173
1174
1175
###
1176
Password reset
1177
###
1178
set_password_reset: (opts) =>
1179
opts = defaults opts,
1180
email_address : required
1181
ttl : required
1182
cb : required # cb(err, uuid)
1183
id = misc.uuid()
1184
@_query
1185
query : "INSERT INTO password_reset"
1186
values :
1187
"id :: UUID" : id
1188
"email_address :: TEXT" : opts.email_address
1189
"expire :: TIMESTAMP" : expire_time(opts.ttl)
1190
cb : (err) =>
1191
opts.cb(err, id)
1192
1193
get_password_reset: (opts) =>
1194
opts = defaults opts,
1195
id : required
1196
cb : required # cb(err, true if allowed and false if not)
1197
@_query
1198
query : 'SELECT expire, email_address FROM password_reset'
1199
where : 'id = $::UUID': opts.id
1200
cb : one_result('email_address', opts.cb)
1201
1202
delete_password_reset: (opts) =>
1203
opts = defaults opts,
1204
id : required
1205
cb : required # cb(err, true if allowed and false if not)
1206
@_query
1207
query : 'DELETE FROM password_reset'
1208
where : 'id = $::UUID': opts.id
1209
cb : opts.cb
1210
1211
record_password_reset_attempt: (opts) =>
1212
opts = defaults opts,
1213
email_address : required
1214
ip_address : required
1215
cb : required # cb(err)
1216
@_query
1217
query : 'INSERT INTO password_reset_attempts'
1218
values :
1219
"id :: UUID" : misc.uuid()
1220
"email_address :: TEXT " : opts.email_address
1221
"ip_address :: INET" : opts.ip_address
1222
"time :: TIMESTAMP" : "NOW()"
1223
cb : opts.cb
1224
1225
count_password_reset_attempts: (opts) =>
1226
opts = defaults opts,
1227
email_address : undefined # must give one of email_address or ip_address
1228
ip_address : undefined
1229
age_s : required # at most this old
1230
cb : required # cb(err)
1231
@_query
1232
query : 'SELECT COUNT(*) FROM password_reset_attempts'
1233
where :
1234
'time >= $::TIMESTAMP' : misc.seconds_ago(opts.age_s)
1235
'email_address = $::TEXT ' : opts.email_address
1236
'ip_address = $::INET ' : opts.ip_address
1237
cb : count_result(opts.cb)
1238
1239
###
1240
Tracking file access
1241
1242
log_file_access is throttled in each server, in the sense that
1243
if it is called with the same input within a minute, those
1244
subsequent calls are ignored. Of course, if multiple servers
1245
are recording file_access then there can be more than one
1246
entry per minute.
1247
###
1248
log_file_access: (opts) =>
1249
opts = defaults opts,
1250
project_id : required
1251
account_id : required
1252
filename : required
1253
cb : undefined
1254
if not @_validate_opts(opts) then return
1255
if @_throttle('log_file_access', 60, opts.project_id, opts.account_id, opts.filename)
1256
opts.cb?()
1257
return
1258
@_query
1259
query : 'INSERT INTO file_access_log'
1260
values :
1261
'id :: UUID ' : misc.uuid()
1262
'project_id :: UUID ' : opts.project_id
1263
'account_id :: UUID ' : opts.account_id
1264
'filename :: TEXT ' : opts.filename
1265
'time :: TIMESTAMP' : 'NOW()'
1266
cb : opts.cb
1267
1268
###
1269
Efficiently get all files access times subject to various constraints...
1270
1271
NOTE: this was not available in RethinkDB version (too painful to implement!), but here it is,
1272
easily sliceable in any way. This could be VERY useful for users!
1273
###
1274
get_file_access: (opts) =>
1275
opts = defaults opts,
1276
start : undefined # start time
1277
end : undefined # end time
1278
project_id : undefined
1279
account_id : undefined
1280
filename : undefined
1281
cb : required
1282
@_query
1283
query : 'SELECT project_id, account_id, filename, time FROM file_access_log'
1284
where :
1285
'time >= $::TIMESTAMP' : opts.start
1286
'time <= $::TIMESTAMP' : opts.end
1287
'project_id = $::UUID' : opts.project_id
1288
'account_id = $::UUID' : opts.account_id
1289
'filename = $::TEXT' : opts.filename
1290
cb : all_results(opts.cb)
1291
1292
# Create a new project with given owner. Returns the generated project_id.
1293
create_project: (opts) =>
1294
opts = defaults opts,
1295
account_id : required # initial owner
1296
title : undefined
1297
description : undefined
1298
cb : required # cb(err, project_id)
1299
if not @_validate_opts(opts) then return
1300
project_id = misc.uuid()
1301
now = new Date()
1302
@_query
1303
query : "INSERT INTO projects"
1304
values :
1305
project_id : project_id
1306
title : opts.title
1307
description : opts.description
1308
created : now
1309
last_edited : now
1310
users : {"#{opts.account_id}":{group:'owner'}}
1311
cb : (err, result) =>
1312
opts.cb(err, if not err then project_id)
1313
1314
###
1315
File editing activity -- users modifying files in any way
1316
- one single table called file_activity
1317
- table also records info about whether or not activity has been seen by users
1318
###
1319
record_file_use: (opts) =>
1320
opts = defaults opts,
1321
project_id : required
1322
path : required
1323
account_id : required
1324
action : required # 'edit', 'read', 'seen', 'chat', etc.?
1325
cb : required
1326
# Doing what's done below (with two queries) is really, really ugly.
1327
# See comment in db-schema.coffee about file_use table -- will redo
1328
# for postgres later...
1329
now = new Date()
1330
entry =
1331
id : @sha1(opts.project_id, opts.path)
1332
project_id : opts.project_id
1333
path : opts.path
1334
if opts.action == 'edit' or opts.action == 'chat'
1335
entry.last_edited = now
1336
async.series([
1337
(cb) =>
1338
@_query
1339
query : 'INSERT INTO file_use'
1340
conflict : 'id'
1341
values : entry
1342
cb : cb
1343
(cb) =>
1344
@_query
1345
query : 'UPDATE file_use'
1346
jsonb_merge :
1347
users : {"#{opts.account_id}": {"#{opts.action}": now}}
1348
where : {id : entry.id}
1349
cb : cb
1350
], opts.cb)
1351
1352
get_file_use: (opts) =>
1353
opts = defaults opts,
1354
max_age_s : undefined
1355
project_id : undefined # don't specify both project_id and project_ids
1356
project_ids : undefined
1357
path : undefined # if given, project_id must be given
1358
cb : required # one entry if path given; otherwise, an array of entries.
1359
if opts.project_id?
1360
if opts.project_ids?
1361
opts.cb("don't specify both project_id and project_ids")
1362
return
1363
else
1364
opts.project_ids = [opts.project_id]
1365
else if not opts.project_ids?
1366
opts.cb("project_id or project_ids must be defined")
1367
return
1368
@_query
1369
query : 'SELECT * FROM file_use'
1370
where :
1371
'last_edited >= $::TIMESTAMP' : if opts.max_age_s then misc.seconds_ago(opts.max_age_s)
1372
'project_id = ANY($)' : opts.project_ids
1373
'path = $::TEXT' : opts.path
1374
order_by : 'last_edited'
1375
cb : if opts.path? then one_result(opts.cb) else all_results(opts.cb)
1376
1377
_validate_opts: (opts) =>
1378
for k, v of opts
1379
if k.slice(k.length-2) == 'id'
1380
if v? and not misc.is_valid_uuid_string(v)
1381
opts.cb?("invalid #{k} -- #{v}")
1382
return false
1383
if k.slice(k.length-3) == 'ids'
1384
for w in v
1385
if not misc.is_valid_uuid_string(w)
1386
opts.cb?("invalid uuid #{w} in #{k} -- #{misc.to_json(v)}")
1387
return false
1388
if k == 'group' and v not in misc.PROJECT_GROUPS
1389
opts.cb?("unknown project group '#{v}'"); return false
1390
if k == 'groups'
1391
for w in v
1392
if w not in misc.PROJECT_GROUPS
1393
opts.cb?("unknown project group '#{w}' in groups"); return false
1394
1395
return true
1396
1397
get_project: (opts) =>
1398
opts = defaults opts,
1399
project_id : required # an array of id's
1400
columns : PROJECT_COLUMNS
1401
cb : required
1402
if not @_validate_opts(opts) then return
1403
@_query
1404
query : "SELECT #{opts.columns.join(',')} FROM projects"
1405
where : 'project_id :: UUID = $' : opts.project_id
1406
cb : one_result(opts.cb)
1407
1408
_get_project_column: (column, project_id, cb) =>
1409
if not misc.is_valid_uuid_string(project_id)
1410
cb("invalid project_id -- #{project_id}: getting column #{column}")
1411
return
1412
@_query
1413
query : "SELECT #{column} FROM projects"
1414
where : 'project_id :: UUID = $' : project_id
1415
cb : one_result(column, cb)
1416
1417
add_user_to_project: (opts) =>
1418
opts = defaults opts,
1419
project_id : required
1420
account_id : required
1421
group : 'collaborator' # see misc.PROJECT_GROUPS above
1422
cb : required # cb(err)
1423
if not @_validate_opts(opts) then return
1424
@_query
1425
query : 'UPDATE projects'
1426
jsonb_merge :
1427
users :
1428
"#{opts.account_id}":
1429
group: opts.group
1430
where :
1431
"project_id = $::UUID": opts.project_id
1432
cb : opts.cb
1433
1434
set_project_status: (opts) =>
1435
opts = defaults opts,
1436
project_id : required
1437
status : required
1438
cb : undefined
1439
@_query
1440
query : "UPDATE projects"
1441
set : {"status::JSONB" : opts.status}
1442
where : {"project_id = $::UUID": opts.project_id}
1443
cb : opts.cb
1444
1445
set_compute_server_status: (opts) =>
1446
opts = defaults opts,
1447
host : required
1448
status : required
1449
cb : undefined
1450
@_query
1451
query : "UPDATE compute_servers"
1452
set : {"status::JSONB": opts.status}
1453
where : {"host = $::TEXT" : opts.host}
1454
cb : opts.cb
1455
1456
1457
# Remove the given collaborator from the project.
1458
# Attempts to remove an *owner* via this function will silently fail (change their group first),
1459
# as will attempts to remove a user not on the project, or to remove from a non-existent project.
1460
remove_collaborator_from_project: (opts) =>
1461
opts = defaults opts,
1462
project_id : required
1463
account_id : required
1464
cb : required # cb(err)
1465
if not @_validate_opts(opts) then return
1466
@_query
1467
query : 'UPDATE projects'
1468
jsonb_set : {users : {"#{opts.account_id}": null}}
1469
where :
1470
'project_id :: UUID = $' : opts.project_id
1471
"users#>>'{#{opts.account_id},group}' != $::TEXT" : 'owner'
1472
cb : opts.cb
1473
1474
# remove any user, even an owner.
1475
remove_user_from_project: (opts) =>
1476
opts = defaults opts,
1477
project_id : required
1478
account_id : required
1479
cb : required # cb(err)
1480
if not @_validate_opts(opts) then return
1481
@_query
1482
query : 'UPDATE projects'
1483
jsonb_set : {users : {"#{opts.account_id}": null}}
1484
where : {'project_id :: UUID = $' : opts.project_id}
1485
cb : opts.cb
1486
1487
# Return a list of the account_id's of all collaborators of the given users.
1488
get_collaborator_ids: (opts) =>
1489
opts = defaults opts,
1490
account_id : required
1491
cb : required
1492
dbg = @_dbg("get_collaborator_ids")
1493
@_query
1494
query : "SELECT DISTINCT jsonb_object_keys(users) FROM projects"
1495
where : "users ? $::TEXT" : opts.account_id
1496
cb : all_results('jsonb_object_keys', opts.cb)
1497
1498
# return list of paths that are public and not disabled in the given project
1499
get_public_paths: (opts) =>
1500
opts = defaults opts,
1501
project_id : required
1502
cb : required
1503
if not @_validate_opts(opts) then return
1504
@_query
1505
query : "SELECT path FROM public_paths"
1506
where : [
1507
"project_id = $::UUID" : opts.project_id,
1508
"disabled IS NOT TRUE"
1509
]
1510
cb : all_results('path', opts.cb)
1511
1512
has_public_path: (opts) =>
1513
opts = defaults opts,
1514
project_id : required
1515
cb : required # cb(err, has_public_path)
1516
@_query
1517
query : "SELECT COUNT(path) FROM public_paths"
1518
where : [
1519
"project_id = $::UUID" : opts.project_id,
1520
"disabled IS NOT TRUE"
1521
]
1522
cb : count_result (err, n) ->
1523
opts.cb(err, n>0)
1524
1525
path_is_public: (opts) =>
1526
opts = defaults opts,
1527
project_id : required
1528
path : required
1529
cb : required
1530
# Get all public paths for the given project_id, then check if path is "in" one according
1531
# to the definition in misc.
1532
# TODO: maybe (?) implement caching + changefeeds so that we only do the get once.
1533
@get_public_paths
1534
project_id : opts.project_id
1535
cb : (err, public_paths) =>
1536
if err
1537
opts.cb(err)
1538
else
1539
opts.cb(undefined, misc.path_is_in_public_paths(opts.path, public_paths))
1540
1541
filter_public_paths: (opts) =>
1542
opts = defaults opts,
1543
project_id : required
1544
path : required
1545
listing : required # files in path [{name:..., isdir:boolean, ....}, ...]
1546
cb : required
1547
# Get all public paths for the given project_id, then check if path is "in" one according
1548
# to the definition in misc.
1549
@get_public_paths
1550
project_id : opts.project_id
1551
cb : (err, public_paths) =>
1552
if err
1553
opts.cb(err)
1554
return
1555
if misc.path_is_in_public_paths(opts.path, public_paths)
1556
# nothing to do -- containing path is public
1557
listing = opts.listing
1558
else
1559
listing = misc.deep_copy(opts.listing) # don't mututate input on general principle
1560
# some files in the listing might not be public, since the containing path isn't public, so we filter
1561
# WARNING: this is kind of stupid since misc.path_is_in_public_paths is badly implemented, especially
1562
# for this sort of iteration. TODO: make this faster. This could matter since is done on server.
1563
try
1564
# we use try/catch here since there is no telling what is in the listing object; the user
1565
# could pass in anything...
1566
listing.files = (x for x in listing.files when \
1567
misc.path_is_in_public_paths(misc.path_to_file(opts.path, x.name), public_paths))
1568
catch
1569
listing.files = []
1570
opts.cb(undefined, listing)
1571
1572
# Set last_edited for this project to right now, and possibly update its size.
1573
# It is safe and efficient to call this function very frequently since it will
1574
# actually hit the database at most once every 30s (per project, per client). In particular,
1575
# once called, it ignores subsequent calls for the same project for 30s.
1576
touch_project: (opts) =>
1577
opts = defaults opts,
1578
project_id : required
1579
cb : undefined
1580
if not @_validate_opts(opts) then return
1581
if @_throttle('touch_project', 30, opts.project_id)
1582
opts.cb?()
1583
return
1584
@_query
1585
query : "UPDATE projects"
1586
set : {last_edited : 'NOW()'}
1587
where : "project_id = $::UUID" : opts.project_id
1588
cb : opts.cb
1589
1590
recently_modified_projects: (opts) =>
1591
opts = defaults opts,
1592
max_age_s : required
1593
cb : required
1594
@_query
1595
query : "SELECT project_id FROM projects"
1596
where : "last_edited >= $::TIMESTAMP" : misc.seconds_ago(opts.max_age_s)
1597
cb : all_results('project_id', opts.cb)
1598
1599
get_open_unused_projects: (opts) =>
1600
opts = defaults opts,
1601
min_age_days : 30 # project must not have been edited in this much time
1602
max_age_days : 120 # project must have been edited at most this long ago
1603
host : required # hostname of where project is opened
1604
cb : required
1605
@_query
1606
query : "SELECT project_id FROM projects"
1607
where : [
1608
"last_edited >= $::TIMESTAMP" : misc.days_ago(opts.max_age_days)
1609
"last_edited <= $::TIMESTAMP" : misc.days_ago(opts.min_age_days)
1610
"host#>>'{host}' = $::TEXT " : opts.host,
1611
"state#>>'{state}' = 'opened'"
1612
]
1613
cb : all_results('project_id', opts.cb)
1614
1615
# cb(err, true if user is in one of the groups for the project)
1616
user_is_in_project_group: (opts) =>
1617
opts = defaults opts,
1618
project_id : required
1619
account_id : undefined
1620
groups : misc.PROJECT_GROUPS
1621
cb : required # cb(err, true if in group)
1622
if not opts.account_id?
1623
# clearly user -- who isn't even signed in -- is not in the group
1624
opts.cb(undefined, false)
1625
return
1626
if not @_validate_opts(opts) then return
1627
@_query
1628
query : 'SELECT COUNT(*) FROM projects'
1629
where :
1630
'project_id :: UUID = $' : opts.project_id
1631
"users#>>'{#{opts.account_id},group}' = ANY($)" : opts.groups
1632
cb : count_result (err, n) =>
1633
if err
1634
opts.cb(err)
1635
else if n == 0
1636
# one more chance -- admin?
1637
@is_admin
1638
account_id : opts.account_id
1639
cb : opts.cb
1640
else
1641
opts.cb(err, n > 0)
1642
1643
# all id's of projects having anything to do with the given account
1644
get_project_ids_with_user: (opts) =>
1645
opts = defaults opts,
1646
account_id : required
1647
cb : required # opts.cb(err, [project_id, project_id, project_id, ...])
1648
if not @_validate_opts(opts) then return
1649
@_query
1650
query : 'SELECT project_id FROM projects'
1651
where : 'users ? $::TEXT' : opts.account_id
1652
cb : all_results('project_id', opts.cb)
1653
1654
# cb(err, array of account_id's of accounts in non-invited-only groups)
1655
# TODO: add something about invited users too and show them in UI!
1656
get_account_ids_using_project: (opts) =>
1657
opts = defaults opts,
1658
project_id : required
1659
cb : required
1660
if not @_validate_opts(opts) then return
1661
@_query
1662
query : 'SELECT users FROM projects'
1663
where : 'project_id :: UUID = $' : opts.project_id
1664
cb : one_result 'users', (err, users) =>
1665
if err
1666
opts.cb(err)
1667
return
1668
opts.cb(undefined, if users? then (id for id,v of users when v.group?.indexOf('invite') == -1) else [])
1669
1670
# Have we successfully (no error) sent an invite to the given email address?
1671
# If so, returns timestamp of when.
1672
# If not, returns 0.
1673
when_sent_project_invite: (opts) =>
1674
opts = defaults opts,
1675
project_id : required
1676
to : required # an email address
1677
cb : required
1678
if not @_validate_opts(opts) then return
1679
@_query
1680
query : "SELECT invite#>'{\"#{opts.to}\"}' AS to FROM projects"
1681
where : 'project_id :: UUID = $' : opts.project_id
1682
cb : one_result 'to', (err, y) =>
1683
opts.cb(err, if not y? or y.error or not y.time then 0 else new Date(y.time))
1684
1685
# call this to record that we have sent an email invite to the given email address
1686
sent_project_invite: (opts) =>
1687
opts = defaults opts,
1688
project_id : required
1689
to : required # an email address
1690
error : undefined # if there was an error set it to this; leave undefined to mean that sending succeeded
1691
cb : undefined
1692
x = {time: new Date()}
1693
if opts.error?
1694
x.error = opts.error
1695
@_query
1696
query : "UPDATE projects"
1697
jsonb_merge :
1698
{invite : "#{opts.to}" : {time: new Date(), error:opts.error}}
1699
where : 'project_id :: UUID = $' : opts.project_id
1700
cb : opts.cb
1701
1702
###
1703
Project host, storage location, and state.
1704
###
1705
set_project_host: (opts) =>
1706
opts = defaults opts,
1707
project_id : required
1708
host : required
1709
cb : required
1710
assigned = new Date()
1711
@_query
1712
query : "UPDATE projects"
1713
jsonb_set :
1714
host : {host:opts.host, assigned:assigned}
1715
where : 'project_id :: UUID = $' : opts.project_id
1716
cb : (err) => opts.cb(err, assigned)
1717
1718
unset_project_host: (opts) =>
1719
opts = defaults opts,
1720
project_id : required
1721
cb : required
1722
@_query
1723
query : "UPDATE projects"
1724
set :
1725
host : null
1726
where : 'project_id :: UUID = $' : opts.project_id
1727
cb : opts.cb
1728
1729
get_project_host: (opts) =>
1730
opts = defaults opts,
1731
project_id : required
1732
cb : required
1733
@_query
1734
query : "SELECT host#>>'{host}' AS host FROM projects"
1735
where : 'project_id :: UUID = $' : opts.project_id
1736
cb : one_result('host', opts.cb)
1737
1738
set_project_storage: (opts) =>
1739
opts = defaults opts,
1740
project_id : required
1741
host : required
1742
cb : required
1743
@get_project_storage
1744
project_id : opts.project_id
1745
cb : (err, current) =>
1746
if err
1747
opts.cb(err)
1748
return
1749
if current?.host? and current.host != opts.host
1750
opts.cb("change storage not implemented yet -- need to implement saving previous host")
1751
else
1752
# easy case -- assigning for the first time
1753
assigned = new Date()
1754
@_query
1755
query : "UPDATE projects"
1756
jsonb_set :
1757
storage : {host:opts.host, assigned:assigned}
1758
where : 'project_id :: UUID = $' : opts.project_id
1759
cb : (err) => opts.cb(err, assigned)
1760
1761
get_project_storage: (opts) =>
1762
opts = defaults opts,
1763
project_id : required
1764
cb : required
1765
@_get_project_column('storage', opts.project_id, opts.cb)
1766
1767
update_project_storage_save: (opts) =>
1768
opts = defaults opts,
1769
project_id : required
1770
cb : required
1771
@_query
1772
query : "UPDATE projects"
1773
jsonb_merge :
1774
storage : {saved:new Date()}
1775
where : 'project_id :: UUID = $' : opts.project_id
1776
cb : opts.cb
1777
1778
set_project_storage_request: (opts) =>
1779
opts = defaults opts,
1780
project_id : required
1781
action : required # 'save', 'close', 'open', 'move'
1782
target : undefined # needed for 'open' and 'move'
1783
cb : required
1784
x =
1785
action : opts.action
1786
requested : new Date()
1787
if opts.target?
1788
x.target = opts.target
1789
@_query
1790
query : "UPDATE projects"
1791
set :
1792
"storage_request::JSONB" : x
1793
where : 'project_id :: UUID = $' : opts.project_id
1794
cb : opts.cb
1795
1796
get_project_storage_request: (opts) =>
1797
opts = defaults opts,
1798
project_id : required
1799
cb : required
1800
@_get_project_column('storage_request', opts.project_id, opts.cb)
1801
1802
set_project_state: (opts) =>
1803
opts = defaults opts,
1804
project_id : required
1805
state : required
1806
time : new Date()
1807
error : undefined
1808
cb : required
1809
if typeof(opts.state) != 'string'
1810
opts.cb("invalid state type")
1811
return
1812
if not COMPUTE_STATES[opts.state]?
1813
opts.cb("state = '#{opts.state}' it not a valid state")
1814
return
1815
state =
1816
state : opts.state
1817
time : opts.time
1818
if opts.error
1819
state.error = opts.error
1820
@_query
1821
query : "UPDATE projects"
1822
set : "state::JSONB" : state
1823
where : 'project_id :: UUID = $' : opts.project_id
1824
cb : opts.cb
1825
1826
get_project_state: (opts) =>
1827
opts = defaults opts,
1828
project_id : required
1829
cb : required
1830
@_get_project_column('state', opts.project_id, opts.cb)
1831
1832
###
1833
Project quotas and upgrades
1834
###
1835
1836
# Returns the total quotas for the project, including any
1837
# upgrades to the base settings.
1838
get_project_quotas: (opts) =>
1839
opts = defaults opts,
1840
project_id : required
1841
cb : required
1842
settings = project_upgrades = undefined
1843
async.parallel([
1844
(cb) =>
1845
@get_project_settings
1846
project_id : opts.project_id
1847
cb : (err, x) =>
1848
settings = x; cb(err)
1849
(cb) =>
1850
@get_project_upgrades
1851
project_id : opts.project_id
1852
cb : (err, x) =>
1853
project_upgrades = x; cb(err)
1854
], (err) =>
1855
if err
1856
opts.cb(err)
1857
else
1858
opts.cb(undefined, misc.map_sum(settings, project_upgrades))
1859
)
1860
1861
# Return mapping from project_id to map listing the upgrades this particular user
1862
# applied to the given project. This only includes project_id's of projects that
1863
# this user may have upgraded in some way.
1864
get_user_project_upgrades: (opts) =>
1865
opts = defaults opts,
1866
account_id : required
1867
cb : required
1868
@_query
1869
query : "SELECT project_id, users#>'{#{opts.account_id},upgrades}' AS upgrades FROM projects"
1870
where : [
1871
'users ? $::TEXT' : opts.account_id, # this is a user of the project
1872
"users#>'{#{opts.account_id},upgrades}' IS NOT NULL" # upgrades are defined
1873
]
1874
cb : (err, result) =>
1875
if err
1876
opts.cb(err)
1877
else
1878
x = {}
1879
for p in result.rows
1880
x[p.project_id] = p.upgrades
1881
opts.cb(undefined, x)
1882
1883
# Ensure that all upgrades applied by the given user to projects are consistent,
1884
# truncating any that exceed their allotment. NOTE: Unless there is a bug,
1885
# the only way the quotas should ever exceed their allotment would be if the
1886
# user is trying to cheat.
1887
ensure_user_project_upgrades_are_valid: (opts) =>
1888
opts = defaults opts,
1889
account_id : required
1890
fix : true # if true, will fix projects in database whose quotas exceed the alloted amount; it is the caller's responsibility to say actually change them.
1891
cb : required # cb(err, excess)
1892
excess = stripe_data = project_upgrades = undefined
1893
async.series([
1894
(cb) =>
1895
async.parallel([
1896
(cb) =>
1897
@_query
1898
query : 'SELECT stripe_customer FROM accounts'
1899
where : 'account_id = $::UUID' : opts.account_id
1900
cb : one_result 'stripe_customer', (err, stripe_customer) =>
1901
stripe_data = stripe_customer?.subscriptions?.data
1902
cb(err)
1903
(cb) =>
1904
@get_user_project_upgrades
1905
account_id : opts.account_id
1906
cb : (err, x) =>
1907
project_upgrades = x
1908
cb(err)
1909
], cb)
1910
(cb) =>
1911
excess = require('smc-util/upgrades').available_upgrades(stripe_data, project_upgrades).excess
1912
if opts.fix
1913
fix = (project_id, cb) =>
1914
upgrades = undefined
1915
async.series([
1916
(cb) =>
1917
@_query
1918
query : "SELECT users#>'{#{opts.account_id},upgrades}' AS upgrades FROM projects"
1919
where : 'project_id = $::UUID' : project_id
1920
cb : one_result 'upgrades', (err, x) =>
1921
upgrades = x; cb(err)
1922
(cb) =>
1923
if not upgrades?
1924
cb(); return
1925
# WORRY: this is dangerous since if something else changed about a user
1926
# between the read/write here, then we would have trouble. (This is milliseconds of time though...)
1927
for k, v of excess[project_id]
1928
upgrades[k] -= v
1929
@_query
1930
query : "UPDATE projects"
1931
where : 'project_id = $::UUID' : project_id
1932
jsonb_merge :
1933
users : {"#{opts.account_id}": {upgrades: upgrades}}
1934
cb : cb
1935
], cb)
1936
async.map(misc.keys(excess), fix, cb)
1937
else
1938
cb()
1939
], (err) =>
1940
opts.cb(err, excess)
1941
)
1942
1943
# Return the sum total of all user upgrades to a particular project
1944
get_project_upgrades: (opts) =>
1945
opts = defaults opts,
1946
project_id : required
1947
cb : required
1948
@_query
1949
query : 'SELECT users FROM projects'
1950
where : 'project_id = $::UUID' : opts.project_id
1951
cb : one_result 'users', (err, users) =>
1952
if err
1953
opts.cb(err); return
1954
upgrades = undefined
1955
if users?
1956
for account_id, info of users
1957
upgrades = misc.map_sum(upgrades, info.upgrades)
1958
opts.cb(undefined, upgrades)
1959
1960
###
1961
Project settings
1962
###
1963
get_project_settings: (opts) =>
1964
opts = defaults opts,
1965
project_id : required
1966
cb : required
1967
@_query
1968
query : "SELECT settings FROM projects"
1969
where : 'project_id = $::UUID' : opts.project_id
1970
cb : one_result 'settings', (err, settings) =>
1971
if err
1972
opts.cb(err)
1973
else if not settings?
1974
opts.cb(undefined, misc.copy(DEFAULT_QUOTAS))
1975
else
1976
settings = misc.coerce_codomain_to_numbers(settings)
1977
quotas = {}
1978
for k, v of DEFAULT_QUOTAS
1979
quotas[k] = if not settings[k]? then v else settings[k]
1980
opts.cb(undefined, quotas)
1981
1982
set_project_settings: (opts) =>
1983
opts = defaults opts,
1984
project_id : required
1985
settings : required # can be any subset of the map
1986
cb : required
1987
@_query
1988
query : "UPDATE projects"
1989
where : 'project_id = $::UUID' : opts.project_id
1990
jsonb_merge : {settings: opts.settings}
1991
cb : opts.cb
1992
1993
1994
###
1995
Stats
1996
###
1997
_count_timespan: (opts) =>
1998
opts = defaults opts,
1999
table : required
2000
field : undefined
2001
age_m : undefined
2002
upper_m : undefined # defaults to zero minutes (i.e. "now")
2003
cb : required
2004
where = {}
2005
if opts.field?
2006
if opts.age_m?
2007
where["#{opts.field} >= $::TIMESTAMP"] = misc.minutes_ago(opts.age_m)
2008
if opts.upper_m?
2009
where["#{opts.field} <= $::TIMESTAMP"] = misc.minutes_ago(opts.upper_m)
2010
@_query
2011
query : "SELECT COUNT(*) FROM #{opts.table}"
2012
where : where
2013
cb : count_result(opts.cb)
2014
2015
_count_opened_files: (opts) =>
2016
opts = defaults opts,
2017
age_m : undefined
2018
key : required
2019
data : required
2020
distinct : required # true or false
2021
cb : required
2022
q = """
2023
WITH filenames AS (
2024
SELECT #{if opts.distinct then 'DISTINCT' else ''} event ->> 'filename' AS fn
2025
FROM project_log
2026
WHERE time BETWEEN $1::TIMESTAMP AND NOW()
2027
AND event @> '{"action" : "open"}'::jsonb
2028
), ext_count AS (
2029
SELECT COUNT(*) as cnt, lower(reverse(split_part(reverse(fn), '.', 1))) AS ext
2030
FROM filenames
2031
GROUP BY ext
2032
)
2033
SELECT ext, cnt
2034
FROM ext_count
2035
WHERE ext IN ('sagews', 'ipynb', 'tex', 'txt', 'py', 'md', 'sage', 'term', 'rnw', 'rmd', 'rst',
2036
'png', 'svg', 'jpeg', 'jpg', 'pdf', 'tasks', 'course', 'sage-chat', 'chat')
2037
ORDER BY ext
2038
"""
2039
2040
post_process = (err, rows) ->
2041
if err
2042
opts.cb(err); return
2043
else
2044
_ = require('underscore')
2045
values = _.object(_.pluck(rows, 'ext'), _.pluck(rows, 'cnt'))
2046
opts.data[opts.key] = values
2047
opts.cb(undefined)
2048
2049
@_query
2050
query : q
2051
params : [misc.minutes_ago(opts.age_m)]
2052
cb : all_results(post_process)
2053
2054
recent_projects: (opts) =>
2055
opts = defaults opts,
2056
age_m : required # return results at most this old
2057
min_age_m : 0 # only returns results at least this old
2058
pluck : undefined # if not given, returns list of project_id's; if given (as an array), returns objects with these fields
2059
cb : required # cb(err, list of strings or objects)
2060
2061
if opts.pluck?
2062
columns = opts.pluck.join(',')
2063
cb = all_results(opts.cb)
2064
else
2065
columns = 'project_id'
2066
cb = all_results('project_id', opts.cb)
2067
@_query
2068
query : "SELECT #{columns} FROM projects"
2069
where :
2070
"last_edited >= $::TIMESTAMP" : misc.minutes_ago(opts.age_m)
2071
"last_edited <= $::TIMESTAMP" : misc.minutes_ago(opts.min_age_m)
2072
cb : cb
2073
2074
get_stats_interval: (opts) =>
2075
opts = defaults opts,
2076
start : required
2077
end : required
2078
cb : required
2079
@_query
2080
query : 'SELECT * FROM stats'
2081
where :
2082
"time >= $::TIMESTAMP" : opts.start
2083
"time <= $::TIMESTAMP" : opts.end
2084
order_by : 'time'
2085
cb : all_results(opts.cb)
2086
2087
# If there is a cached version of stats (which has given ttl) return that -- this could have
2088
# been computed by any of the hubs. If there is no cached version, compute new one and store
2089
# in cache for ttl seconds.
2090
get_stats: (opts) =>
2091
opts = defaults opts,
2092
ttl_dt : 15 # 15 secs subtracted from ttl to compensate for computation duration when called via a cronjob
2093
ttl : 5*60 # how long cached version lives (in seconds)
2094
ttl_db : 30 # how long a valid result from a db query is cached in any case
2095
update : true # true: recalculate if older than ttl; false: don't recalculate and pick it from the DB (locally cached for ttl secs)
2096
cb : undefined
2097
stats = undefined
2098
start_t = process.hrtime()
2099
dbg = @_dbg('get_stats')
2100
async.series([
2101
(cb) =>
2102
dbg("using cached stats?")
2103
if @_stats_cached?
2104
# decide if cache should be used -- tighten interval if we are allowed to update
2105
offset_dt = if opts.update then opts.ttl_dt else 0
2106
is_cache_recent = @_stats_cached.time > misc.seconds_ago(opts.ttl - offset_dt)
2107
# in case we aren't allowed to update and the cache is outdated, do not query db too often
2108
did_query_recently = @_stats_cached_db_query > misc.seconds_ago(opts.ttl_db)
2109
if is_cache_recent or did_query_recently
2110
stats = @_stats_cached
2111
dbg("using locally cached stats from #{(new Date() - stats.time) / 1000} secs ago.")
2112
cb(); return
2113
@_query
2114
query : "SELECT * FROM stats ORDER BY time DESC LIMIT 1"
2115
cb : one_result (err, x) =>
2116
if err or not x?
2117
dbg("problem with query -- no stats in db?")
2118
cb(err); return
2119
# query successful, since x exists
2120
@_stats_cached_db_query = new Date()
2121
if opts.update and x.time < misc.seconds_ago(opts.ttl - opts.ttl_dt)
2122
dbg("cache outdated -- will update stats")
2123
cb()
2124
else
2125
dbg("using db stats from #{(new Date() - x.time) / 1000} secs ago.")
2126
stats = x
2127
# storing still valid result in local cache
2128
@_stats_cached = misc.deep_copy(stats)
2129
cb()
2130
(cb) =>
2131
if stats?
2132
cb(); return
2133
else if not opts.update
2134
dbg("warning: no recent stats but not allowed to update")
2135
cb(); return
2136
dbg("querying all stats from the DB")
2137
stats =
2138
time : new Date()
2139
projects_created : {}
2140
projects_edited : {}
2141
accounts_created : {}
2142
files_opened : {distinct: {}, total:{}}
2143
R = RECENT_TIMES
2144
K = RECENT_TIMES_KEY
2145
stats_tasks = [
2146
(cb) => @_count_timespan(table:'accounts', cb:(err, x) => stats.accounts = x; cb(err))
2147
(cb) => @_count_timespan(table:'projects', cb:(err, x) => stats.projects = x; cb(err))
2148
(cb) => @_count_timespan(table:'projects', field: 'last_edited', age_m: R.active, cb: (err, x) => stats.projects_edited[K.active] = x; cb(err))
2149
(cb) =>
2150
@_query
2151
query : 'SELECT expire, host, clients FROM hub_servers'
2152
cb : all_results (err, hub_servers) =>
2153
if err
2154
cb(err)
2155
else
2156
now = new Date()
2157
stats.hub_servers = []
2158
for x in hub_servers
2159
if x.expire > now
2160
delete x.expire
2161
stats.hub_servers.push(x)
2162
cb()
2163
]
2164
for tkey in ['last_month', 'last_week', 'last_day', 'last_hour']
2165
do (tkey) =>
2166
stats_tasks.push((cb) => @_count_opened_files(age_m:R[tkey], key:K[tkey], data:stats.files_opened.distinct, distinct:true, cb:cb))
2167
stats_tasks.push((cb) => @_count_opened_files(age_m:R[tkey], key:K[tkey], data:stats.files_opened.total, distinct:false, cb:cb))
2168
stats_tasks.push((cb) => @_count_timespan(table:'projects', field: 'last_edited', age_m: R[tkey], cb: (err, x) => stats.projects_edited[K[tkey]] = x; cb(err)))
2169
stats_tasks.push((cb) => @_count_timespan(table:'projects', field: 'created', age_m: R[tkey], cb: (err, x) => stats.projects_created[K[tkey]] = x; cb(err)))
2170
stats_tasks.push((cb) => @_count_timespan(table:'accounts', field: 'created', age_m: R[tkey], cb: (err, x) => stats.accounts_created[K[tkey]] = x; cb(err)))
2171
2172
# this was running in parallel, but there is no hurry updating the stats...
2173
# async.parallelLimit(stats_tasks, MAP_LIMIT, (err) =>
2174
async.series(stats_tasks, (err) =>
2175
if err
2176
cb(err)
2177
else
2178
elapsed_t = process.hrtime(start_t)
2179
duration_s = (elapsed_t[0] + elapsed_t[1] / 1e9).toFixed(4)
2180
dbg("everything succeeded above after #{duration_s} secs -- now insert stats")
2181
# storing in local and db cache
2182
stats.id = misc.uuid()
2183
@_stats_cached = misc.deep_copy(stats)
2184
@_query
2185
query : 'INSERT INTO stats'
2186
values : stats
2187
cb : cb
2188
)
2189
], (err) =>
2190
dbg("get_stats final CB: (#{misc.to_json(err)}, #{misc.to_json(stats)})")
2191
opts.cb?(err, stats)
2192
)
2193
2194
get_active_student_stats: (opts) =>
2195
opts = defaults opts,
2196
cb : required
2197
dbg = @_dbg('get_active_student_stats')
2198
dbg()
2199
@_query
2200
query : "SELECT project_id, course, last_edited, settings, users FROM projects WHERE course IS NOT NULL AND last_edited >= $1"
2201
params : [misc.days_ago(30)]
2202
cb : all_results (err, t) =>
2203
if err
2204
opts.cb(err)
2205
return
2206
days14 = misc.days_ago(14)
2207
days7 = misc.days_ago(7)
2208
days1 = misc.days_ago(1)
2209
# student pay means that the student is required to pay
2210
num_student_pay = (x for x in t when x.course.pay).length
2211
# prof pay means that student isn't required to pay but
2212
# nonetheless project is on members only host
2213
num_prof_pay = 0
2214
for x in t
2215
if not x.course.pay # student isn't paying
2216
if x.settings?.member_host
2217
num_prof_pay += 1
2218
continue
2219
for _, d of x.users
2220
if d.upgrades?.member_host
2221
num_prof_pay += 1
2222
continue
2223
# free - neither student pays, and also project not on members only server
2224
num_free = t.length - num_prof_pay - num_student_pay
2225
conversion_rate = if t.length then 100*(num_student_pay + num_prof_pay) / t.length else 0
2226
data =
2227
conversion_rate : conversion_rate
2228
num_student_pay : num_student_pay
2229
num_prof_pay : num_prof_pay
2230
num_free : num_free
2231
num_1days : (x for x in t when x.last_edited >= days1).length
2232
num_7days : (x for x in t when x.last_edited >= days7).length
2233
num_14days : (x for x in t when x.last_edited >= days14).length
2234
num_30days : t.length
2235
opts.cb(undefined, data)
2236
2237
2238
###
2239
Hub servers
2240
###
2241
register_hub: (opts) =>
2242
opts = defaults opts,
2243
host : required
2244
port : required
2245
clients : required
2246
ttl : required
2247
cb : required
2248
# Since multiple hubs can run on the same host (but with different ports) and the host is the primary
2249
# key, we combine the host and port number in the host name for the db. The hub_servers table is only
2250
# used for tracking connection stats, so this is safe.
2251
@_query
2252
query : "INSERT INTO hub_servers"
2253
values :
2254
"host :: TEXT " : "#{opts.host}-#{opts.port}"
2255
"port :: INTEGER " : opts.port
2256
"clients :: INTEGER " : opts.clients
2257
"expire :: TIMESTAMP" : expire_time(opts.ttl)
2258
conflict : 'host'
2259
cb : opts.cb
2260
2261
get_hub_servers: (opts) =>
2262
opts = defaults opts,
2263
cb : required
2264
@_query
2265
query : "SELECT * FROM hub_servers"
2266
cb : all_results (err, v) =>
2267
if err
2268
opts.cb(err)
2269
return
2270
w = []
2271
to_delete = []
2272
now = new Date()
2273
for x in v
2274
if x.expire and x.expire <= now
2275
to_delete.push(x.host)
2276
else
2277
w.push(x)
2278
if to_delete.length > 0
2279
@_query
2280
query : "DELETE FROM hub_servers"
2281
where : "host = ANY($)" : to_delete
2282
cb : (err) => opts.cb(err, w)
2283
else
2284
opts.cb(undefined, w)
2285
2286
###
2287
Compute servers
2288
###
2289
save_compute_server: (opts) =>
2290
opts = defaults opts,
2291
host : required
2292
dc : required
2293
port : required
2294
secret : required
2295
experimental : false
2296
member_host : false
2297
cb : required
2298
@_query
2299
query : "INSERT INTO compute_servers"
2300
values :
2301
'host :: TEXT ' : opts.host
2302
'dc :: TEXT ' : opts.dc
2303
'port :: INTEGER ' : opts.port
2304
'secret :: TEXT ' : opts.secret
2305
'experimental :: BOOLEAN ' : opts.experimental
2306
'member_host :: BOOLEAN ' : opts.member_host
2307
conflict : 'host'
2308
cb : opts.cb
2309
2310
get_compute_server: (opts) =>
2311
opts = defaults opts,
2312
host : required
2313
cb : required
2314
@_query
2315
query : "SELECT * FROM compute_servers"
2316
where :
2317
"host = $::TEXT" : opts.host
2318
cb : one_result(opts.cb)
2319
2320
get_all_compute_servers: (opts) =>
2321
opts = defaults opts,
2322
experimental : undefined
2323
cb : required
2324
@_query
2325
query : "SELECT * FROM compute_servers"
2326
where : "host = $::TEXT" : opts.host
2327
cb : all_results (err, servers) =>
2328
if err
2329
opts.cb(err)
2330
else
2331
if opts.experimental?
2332
is_experimental = !!opts.experimental
2333
# just filter experimental client side, since so few servers...
2334
servers = (server for server in servers when !!server.experimental == is_experimental)
2335
opts.cb(undefined, servers)
2336
2337
get_projects_on_compute_server: (opts) =>
2338
opts = defaults opts,
2339
compute_server : required # hostname of the compute server
2340
columns : ['project_id']
2341
cb : required
2342
@_query
2343
query : "SELECT #{opts.columns.join(',')} FROM projects"
2344
where :
2345
"host @> $::JSONB" : {host:opts.compute_server}
2346
cb : all_results(opts.cb)
2347
2348
is_member_host_compute_server: (opts) =>
2349
opts = defaults opts,
2350
host : required # hostname of the compute server
2351
cb : required
2352
@_query
2353
query : "SELECT member_host FROM compute_servers"
2354
where : "host = $::TEXT" : opts.host
2355
cache : true # cache result (for a few seconds), since this is very unlikely to change.
2356
cb : one_result 'member_host', (err, member_host) =>
2357
opts.cb(err, !!member_host)
2358
2359
2360