CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

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