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/hub/client.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
Client = a client that is connected via a persistent connection to the hub
8
###
9
10
{EventEmitter} = require('events')
11
uuid = require('uuid')
12
async = require('async')
13
Cookies = require('cookies') # https://github.com/jed/cookies
14
misc = require('@cocalc/util/misc')
15
{defaults, required, to_safe_str} = misc
16
message = require('@cocalc/util/message')
17
access = require('./access')
18
clients = require('./clients').getClients()
19
auth = require('./auth')
20
auth_token = require('./auth-token')
21
local_hub_connection = require('./local_hub_connection')
22
sign_in = require('@cocalc/server/hub/sign-in')
23
hub_projects = require('./projects')
24
{StripeClient} = require('@cocalc/server/stripe/client')
25
{send_email, send_invite_email} = require('./email')
26
manageApiKeys = require("@cocalc/server/api/manage").default
27
{legacyManageApiKey} = require("@cocalc/server/api/manage")
28
purchase_license = require('@cocalc/server/licenses/purchase').default
29
db_schema = require('@cocalc/util/db-schema')
30
{ escapeHtml } = require("escape-html")
31
{CopyPath} = require('./copy-path')
32
{ REMEMBER_ME_COOKIE_NAME } = require("@cocalc/backend/auth/cookie-names");
33
generateHash = require("@cocalc/server/auth/hash").default;
34
passwordHash = require("@cocalc/backend/auth/password-hash").default;
35
llm = require('@cocalc/server/llm/index');
36
jupyter_execute = require('@cocalc/server/jupyter/execute').execute;
37
jupyter_kernels = require('@cocalc/server/jupyter/kernels').default;
38
create_project = require("@cocalc/server/projects/create").default;
39
user_search = require("@cocalc/server/accounts/search").default;
40
collab = require('@cocalc/server/projects/collab');
41
delete_passport = require('@cocalc/server/auth/sso/delete-passport').delete_passport;
42
setEmailAddress = require("@cocalc/server/accounts/set-email-address").default;
43
44
{one_result} = require("@cocalc/database")
45
46
path_join = require('path').join
47
base_path = require('@cocalc/backend/base-path').default
48
49
underscore = require('underscore')
50
51
{callback, delay} = require('awaiting')
52
{callback2} = require('@cocalc/util/async-utils')
53
54
{record_user_tracking} = require('@cocalc/database/postgres/user-tracking')
55
{project_has_network_access} = require('@cocalc/database/postgres/project-queries')
56
{is_paying_customer} = require('@cocalc/database/postgres/account-queries')
57
{get_personal_user} = require('@cocalc/database/postgres/personal')
58
59
{RESEND_INVITE_INTERVAL_DAYS} = require("@cocalc/util/consts/invites")
60
61
removeLicenseFromProject = require('@cocalc/server/licenses/remove-from-project').default
62
addLicenseToProject = require('@cocalc/server/licenses/add-to-project').default
63
64
DEBUG2 = !!process.env.SMC_DEBUG2
65
66
REQUIRE_ACCOUNT_TO_EXECUTE_CODE = false
67
68
# Temporarily to handle old clients for a few days.
69
JSON_CHANNEL = '\u0000'
70
71
# Anti DOS parameters:
72
# If a client sends a burst of messages, we space handling them out by this many milliseconds:
73
# (this even includes keystrokes when using the terminal)
74
MESG_QUEUE_INTERVAL_MS = 0
75
# If a client sends a massive burst of messages, we discard all but the most recent this many of them:
76
# The client *should* be implemented in a way so that this never happens, and when that is
77
# the case -- according to our loging -- we might switch to immediately banning clients that
78
# hit these limits...
79
MESG_QUEUE_MAX_COUNT = 300
80
MESG_QUEUE_MAX_WARN = 50
81
82
# Any messages larger than this is dropped (it could take a long time to handle, by a de-JSON'ing attack, etc.).
83
# On the other hand, it is good to make this large enough that projects can save
84
MESG_QUEUE_MAX_SIZE_MB = 20
85
86
# How long to cache a positive authentication for using a project.
87
CACHE_PROJECT_AUTH_MS = 1000*60*15 # 15 minutes
88
89
# How long all info about a websocket Client connection
90
# is kept in memory after a user disconnects. This makes it
91
# so that if they quickly reconnect, the connections to projects
92
# and other state doesn't have to be recomputed.
93
CLIENT_DESTROY_TIMER_S = 60*10 # 10 minutes
94
#CLIENT_DESTROY_TIMER_S = 0.1 # instant -- for debugging
95
96
CLIENT_MIN_ACTIVE_S = 45
97
98
# How frequently we tell the browser clients to report metrics back to us.
99
# Set to 0 to completely disable metrics collection from clients.
100
CLIENT_METRICS_INTERVAL_S = if DEBUG2 then 15 else 60*2
101
102
# recording metrics and statistics
103
metrics_recorder = require('./metrics-recorder')
104
105
# setting up client metrics
106
mesg_from_client_total = metrics_recorder.new_counter('mesg_from_client_total',
107
'counts Client::handle_json_message_from_client invocations', ['event'])
108
push_to_client_stats_h = metrics_recorder.new_histogram('push_to_client_histo_ms', 'Client: push_to_client',
109
buckets : [1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000]
110
labels: ['event']
111
)
112
113
# All known metrics from connected clients. (Map from id to metrics.)
114
# id is deleted from this when client disconnects.
115
client_metrics = metrics_recorder.client_metrics
116
117
if not misc.is_object(client_metrics)
118
throw Error("metrics_recorder must have a client_metrics attribute map")
119
120
class exports.Client extends EventEmitter
121
constructor: (opts) ->
122
super()
123
@_opts = defaults opts,
124
conn : undefined
125
logger : undefined
126
database : required
127
projectControl : required
128
host : undefined
129
port : undefined
130
personal : undefined
131
132
@conn = @_opts.conn
133
@logger = @_opts.logger
134
@database = @_opts.database
135
@projectControl = @_opts.projectControl
136
137
@_when_connected = new Date()
138
139
@_messages =
140
being_handled : {}
141
total_time : 0
142
count : 0
143
144
# The variable account_id is either undefined or set to the
145
# account id of the user that this session has successfully
146
# authenticated as. Use @account_id to decide whether or not
147
# it is safe to carry out a given action.
148
@account_id = undefined
149
150
if @conn?
151
# has a persistent connection, e.g., NOT just used for an API
152
@init_conn()
153
else
154
@id = misc.uuid()
155
156
@copy_path = new CopyPath(@)
157
158
init_conn: =>
159
# initialize everything related to persistent connections
160
@_data_handlers = {}
161
@_data_handlers[JSON_CHANNEL] = @handle_json_message_from_client
162
163
# The persistent sessions that this client starts.
164
@compute_session_uuids = []
165
166
@install_conn_handlers()
167
168
@ip_address = @conn.address.ip
169
170
# A unique id -- can come in handy
171
@id = @conn.id
172
173
# Setup remember-me related cookie handling
174
@cookies = {}
175
c = new Cookies(@conn.request)
176
@_remember_me_value = c.get(REMEMBER_ME_COOKIE_NAME)
177
178
@check_for_remember_me()
179
180
# Security measure: check every 5 minutes that remember_me
181
# cookie used for login is still valid. If the cookie is gone
182
# and this fails, user gets a message, and see that they must sign in.
183
@_remember_me_interval = setInterval(@check_for_remember_me, 1000*60*5)
184
185
if CLIENT_METRICS_INTERVAL_S
186
@push_to_client(message.start_metrics(interval_s:CLIENT_METRICS_INTERVAL_S))
187
188
touch: (opts={}) =>
189
if not @account_id # not logged in
190
opts.cb?('not logged in')
191
return
192
opts = defaults opts,
193
project_id : undefined
194
path : undefined
195
action : 'edit'
196
force : false
197
cb : undefined
198
# touch -- indicate by changing field in database that this user is active.
199
# We do this at most once every CLIENT_MIN_ACTIVE_S seconds, for given choice
200
# of project_id, path (unless force is true).
201
if not @_touch_lock?
202
@_touch_lock = {}
203
key = "#{opts.project_id}-#{opts.path}-#{opts.action}"
204
if not opts.force and @_touch_lock[key]
205
opts.cb?("touch lock")
206
return
207
opts.account_id = @account_id
208
@_touch_lock[key] = true
209
delete opts.force
210
@database.touch(opts)
211
212
setTimeout((()=>delete @_touch_lock[key]), CLIENT_MIN_ACTIVE_S*1000)
213
214
install_conn_handlers: () =>
215
dbg = @dbg('install_conn_handlers')
216
if @_destroy_timer?
217
clearTimeout(@_destroy_timer)
218
delete @_destroy_timer
219
220
@conn.on "data", (data) =>
221
@handle_data_from_client(data)
222
223
@conn.on "end", () =>
224
dbg("connection: hub <--> client(id=#{@id}, address=#{@ip_address}) -- CLOSED")
225
@destroy()
226
###
227
# I don't think this destroy_timer is of any real value at all unless
228
# we were to fully maintain client state while they are gone. Doing this
229
# is a serious liability, e.g., in a load-spike situation.
230
# CRITICAL -- of course we need to cancel all changefeeds when user disconnects,
231
# even temporarily, since messages could be dropped otherwise. (The alternative is to
232
# cache all messages in the hub, which has serious memory implications.)
233
@query_cancel_all_changefeeds()
234
# Actually destroy Client in a few minutes, unless user reconnects
235
# to this session. Often the user may have a temporary network drop,
236
# and we keep everything waiting for them for short time
237
# in case this happens.
238
@_destroy_timer = setTimeout(@destroy, 1000*CLIENT_DESTROY_TIMER_S)
239
###
240
241
dbg("connection: hub <--> client(id=#{@id}, address=#{@ip_address}) ESTABLISHED")
242
243
dbg: (desc) =>
244
if @logger?.debug
245
return (args...) => @logger.debug("Client(#{@id}).#{desc}:", args...)
246
else
247
return ->
248
249
destroy: () =>
250
dbg = @dbg('destroy')
251
dbg("destroy connection: hub <--> client(id=#{@id}, address=#{@ip_address}) -- CLOSED")
252
253
if @id
254
# cancel any outstanding queries.
255
@database.cancel_user_queries(client_id:@id)
256
257
delete @_project_cache
258
delete client_metrics[@id]
259
clearInterval(@_remember_me_interval)
260
@query_cancel_all_changefeeds()
261
@closed = true
262
@emit('close')
263
@compute_session_uuids = []
264
c = clients[@id]
265
delete clients[@id]
266
dbg("num_clients=#{misc.len(clients)}")
267
if c? and c.call_callbacks?
268
for id,f of c.call_callbacks
269
f("connection closed")
270
delete c.call_callbacks
271
for h in local_hub_connection.all_local_hubs()
272
h.free_resources_for_client_id(@id)
273
274
remember_me_failed: (reason) =>
275
return if not @conn?
276
@signed_out() # so can't do anything with projects, etc.
277
@push_to_client(message.remember_me_failed(reason:reason))
278
279
get_personal_user: () =>
280
if @account_id or not @conn? or not @_opts.personal
281
# there is only one account
282
return
283
dbg = @dbg("check_for_remember_me")
284
dbg("personal mode")
285
try
286
signed_in_mesg = {account_id:await get_personal_user(@database), event:'signed_in'}
287
# sign them in if not already signed in (due to async this could happen
288
# by get_personal user getting called twice at once).
289
if @account_id != signed_in_mesg.account_id
290
signed_in_mesg.hub = @_opts.host + ':' + @_opts.port
291
@signed_in(signed_in_mesg)
292
@push_to_client(signed_in_mesg)
293
catch err
294
dbg("remember_me: personal mode error", err.toString())
295
@remember_me_failed("error getting personal user -- #{err}")
296
return
297
298
check_for_remember_me: () =>
299
return if not @conn?
300
dbg = @dbg("check_for_remember_me")
301
302
if @_opts.personal
303
@get_personal_user()
304
return
305
306
value = @_remember_me_value
307
if not value?
308
@remember_me_failed("no remember_me cookie")
309
return
310
x = value.split('$')
311
if x.length != 4
312
@remember_me_failed("invalid remember_me cookie")
313
return
314
try
315
hash = generateHash(x[0], x[1], x[2], x[3])
316
catch err
317
dbg("unable to generate hash from '#{value}' -- #{err}")
318
@remember_me_failed("invalid remember_me cookie")
319
return
320
321
dbg("checking for remember_me cookie with hash='#{hash.slice(0,15)}...'") # don't put all in log -- could be dangerous
322
@database.get_remember_me
323
hash : hash
324
cb : (error, signed_in_mesg) =>
325
dbg("remember_me: got ", error)
326
if error
327
@remember_me_failed("error accessing database")
328
return
329
if not signed_in_mesg or not signed_in_mesg.account_id
330
@remember_me_failed("remember_me deleted or expired")
331
return
332
# sign them in if not already signed in
333
if @account_id != signed_in_mesg.account_id
334
# DB only tells us the account_id, but the hub might have changed from last time
335
signed_in_mesg.hub = @_opts.host + ':' + @_opts.port
336
@hash_session_id = hash
337
@signed_in(signed_in_mesg)
338
@push_to_client(signed_in_mesg)
339
340
push_to_client: (mesg, cb) =>
341
###
342
Pushing messages to this particular connected client
343
###
344
if @closed
345
cb?("disconnected")
346
return
347
dbg = @dbg("push_to_client")
348
349
if mesg.event != 'pong'
350
dbg("hub --> client (client=#{@id}): #{misc.trunc(to_safe_str(mesg),300)}")
351
#dbg("hub --> client (client=#{@id}): #{misc.trunc(JSON.stringify(mesg),1000)}")
352
#dbg("hub --> client (client=#{@id}): #{JSON.stringify(mesg)}")
353
354
if mesg.id?
355
start = @_messages.being_handled[mesg.id]
356
if start?
357
time_taken = new Date() - start
358
delete @_messages.being_handled[mesg.id]
359
@_messages.total_time += time_taken
360
@_messages.count += 1
361
avg = Math.round(@_messages.total_time / @_messages.count)
362
dbg("[#{time_taken} mesg_time_ms] [#{avg} mesg_avg_ms] -- mesg.id=#{mesg.id}")
363
push_to_client_stats_h.observe({event:mesg.event}, time_taken)
364
365
# If cb *is* given and mesg.id is *not* defined, then
366
# we also setup a listener for a response from the client.
367
listen = cb? and not mesg.id?
368
if listen
369
# This message is not a response to a client request.
370
# Instead, we are initiating a request to the user and we
371
# want a result back (hence cb? being defined).
372
mesg.id = misc.uuid()
373
if not @call_callbacks?
374
@call_callbacks = {}
375
@call_callbacks[mesg.id] = cb
376
f = () =>
377
g = @call_callbacks?[mesg.id]
378
if g?
379
delete @call_callbacks[mesg.id]
380
g("timed out")
381
setTimeout(f, 15000) # timeout after some seconds
382
383
t = new Date()
384
data = misc.to_json_socket(mesg)
385
tm = new Date() - t
386
if tm > 10
387
dbg("mesg.id=#{mesg.id}: time to json=#{tm}ms; length=#{data.length}; value='#{misc.trunc(data, 500)}'")
388
@push_data_to_client(data)
389
if not listen
390
cb?()
391
return
392
393
push_data_to_client: (data) ->
394
return if not @conn?
395
if @closed
396
return
397
@conn.write(data)
398
399
error_to_client: (opts) =>
400
opts = defaults opts,
401
id : undefined
402
error : required
403
if opts.error instanceof Error
404
# Javascript Errors as come up with exceptions don't JSON.
405
# Since the point is just to show an error to the client,
406
# it is better to send back the string!
407
opts.error = opts.error.toString()
408
@push_to_client(message.error(id:opts.id, error:opts.error))
409
410
success_to_client: (opts) =>
411
opts = defaults opts,
412
id : required
413
@push_to_client(message.success(id:opts.id))
414
415
signed_in: (signed_in_mesg) =>
416
return if not @conn?
417
# Call this method when the user has successfully signed in.
418
419
@signed_in_mesg = signed_in_mesg # save it, since the properties are handy to have.
420
421
# Record that this connection is authenticated as user with given uuid.
422
@account_id = signed_in_mesg.account_id
423
424
sign_in.record_sign_in
425
ip_address : @ip_address
426
successful : true
427
account_id : signed_in_mesg.account_id
428
database : @database
429
430
# Get user's group from database.
431
@get_groups()
432
433
signed_out: () =>
434
@account_id = undefined
435
436
# Setting and getting HTTP-only cookies via Primus + AJAX
437
get_cookie: (opts) =>
438
opts = defaults opts,
439
name : required
440
cb : required # cb(undefined, value)
441
if not @conn?.id?
442
# no connection or connection died
443
return
444
@once("get_cookie-#{opts.name}", (value) -> opts.cb(value))
445
@push_to_client(message.cookies(id:@conn.id, get:opts.name, url:path_join(base_path, "cookies")))
446
447
448
invalidate_remember_me: (opts) =>
449
return if not @conn?
450
451
opts = defaults opts,
452
cb : required
453
454
if @hash_session_id?
455
@database.delete_remember_me
456
hash : @hash_session_id
457
cb : opts.cb
458
else
459
opts.cb()
460
461
###
462
Our realtime socket connection might only support one connection
463
between the client and
464
server, so we multiplex multiple channels over the same
465
connection. There is one base channel for JSON messages called
466
JSON_CHANNEL, which themselves can be routed to different
467
callbacks, etc., by the client code. There are 16^4-1 other
468
channels, which are for sending raw data. The raw data messages
469
are prepended with a UTF-16 character that identifies the
470
channel. The channel character is random (which might be more
471
secure), and there is no relation between the channels for two
472
distinct clients.
473
###
474
475
handle_data_from_client: (data) =>
476
return if not @conn?
477
dbg = @dbg("handle_data_from_client")
478
## Only enable this when doing low level debugging -- performance impacts AND leakage of dangerous info!
479
if DEBUG2
480
dbg("handle_data_from_client('#{misc.trunc(data.toString(),400)}')")
481
482
# TODO: THIS IS A SIMPLE anti-DOS measure; it might be too
483
# extreme... we shall see. It prevents a number of attacks,
484
# e.g., users storing a multi-gigabyte worksheet title,
485
# etc..., which would (and will) otherwise require care with
486
# every single thing we store.
487
488
# TODO: the two size things below should be specific messages (not generic error_to_client), and
489
# be sensibly handled by the client.
490
if data.length >= MESG_QUEUE_MAX_SIZE_MB * 10000000
491
# We don't parse it, we don't look at it, we don't know it's id. This shouldn't ever happen -- and probably would only
492
# happen because of a malicious attacker. JSON parsing arbitrarily large strings would
493
# be very dangerous, and make crashing the server way too easy.
494
# We just respond with this error below. The client should display to the user all id-less errors.
495
msg = "The server ignored a huge message since it exceeded the allowed size limit of #{MESG_QUEUE_MAX_SIZE_MB}MB. Please report what caused this if you can."
496
@logger?.error(msg)
497
@error_to_client(error:msg)
498
return
499
500
if not @_handle_data_queue?
501
@_handle_data_queue = []
502
503
# The rest of the function is basically the same as "h(data.slice(1))", except that
504
# it ensure that if there is a burst of messages, then (1) we handle at most 1 message
505
# per client every MESG_QUEUE_INTERVAL_MS, and we drop messages if there are too many.
506
# This is an anti-DOS measure.
507
@_handle_data_queue.push([@handle_json_message_from_client, data])
508
509
if @_handle_data_queue_empty_function?
510
return
511
512
# define a function to empty the queue
513
@_handle_data_queue_empty_function = () =>
514
if @_handle_data_queue.length == 0
515
# done doing all tasks
516
delete @_handle_data_queue_empty_function
517
return
518
519
if @_handle_data_queue.length > MESG_QUEUE_MAX_WARN
520
dbg("MESG_QUEUE_MAX_WARN(=#{MESG_QUEUE_MAX_WARN}) exceeded (=#{@_handle_data_queue.length}) -- just a warning")
521
522
# drop oldest message to keep
523
if @_handle_data_queue.length > MESG_QUEUE_MAX_COUNT
524
dbg("MESG_QUEUE_MAX_COUNT(=#{MESG_QUEUE_MAX_COUNT}) exceeded (=#{@_handle_data_queue.length}) -- drop oldest messages")
525
while @_handle_data_queue.length > MESG_QUEUE_MAX_COUNT
526
discarded_mesg = @_handle_data_queue.shift()
527
data = discarded_mesg?[1]
528
dbg("discarded_mesg='#{misc.trunc(data?.toString?(),1000)}'")
529
530
531
# get task
532
task = @_handle_data_queue.shift()
533
# do task
534
task[0](task[1])
535
# do next one in >= MESG_QUEUE_INTERVAL_MS
536
setTimeout( @_handle_data_queue_empty_function, MESG_QUEUE_INTERVAL_MS )
537
538
@_handle_data_queue_empty_function()
539
540
register_data_handler: (h) ->
541
return if not @conn?
542
# generate a channel character that isn't already taken -- if these get too large,
543
# this will break (see, e.g., http://blog.fgribreau.com/2012/05/how-to-fix-could-not-decode-text-frame.html);
544
# however, this is a counter for *each* individual user connection, so they won't get too big.
545
# Ultimately, we'll redo things to use primus/websocket channel support, which should be much more powerful
546
# and faster.
547
if not @_last_channel?
548
@_last_channel = 1
549
while true
550
@_last_channel += 1
551
channel = String.fromCharCode(@_last_channel)
552
if not @_data_handlers[channel]?
553
break
554
@_data_handlers[channel] = h
555
return channel
556
557
###
558
Message handling functions:
559
560
Each function below that starts with mesg_ handles a given
561
message type (an event). The implementations of many of the
562
handlers are somewhat long/involved, so the function below
563
immediately calls another function defined elsewhere. This will
564
make it easier to refactor code to other modules, etc., later.
565
This approach also clarifies what exactly about this object
566
is used to implement the relevant functionality.
567
###
568
handle_json_message_from_client: (data) =>
569
return if not @conn?
570
if @_ignore_client
571
return
572
try
573
mesg = misc.from_json_socket(data)
574
catch error
575
@logger?.error("error parsing incoming mesg (invalid JSON): #{mesg}")
576
return
577
dbg = @dbg('handle_json_message_from_client')
578
if mesg.event != 'ping'
579
dbg("hub <-- client: #{misc.trunc(to_safe_str(mesg), 120)}")
580
581
# check for message that is coming back in response to a request from the hub
582
if @call_callbacks? and mesg.id?
583
f = @call_callbacks[mesg.id]
584
if f?
585
delete @call_callbacks[mesg.id]
586
f(undefined, mesg)
587
return
588
589
if mesg.id?
590
@_messages.being_handled[mesg.id] = new Date()
591
592
handler = @["mesg_#{mesg.event}"]
593
if handler?
594
try
595
await handler(mesg)
596
catch err
597
# handler *should* handle any possible error, but just in case something
598
# not expected goes wrong... we do this
599
@error_to_client(id:mesg.id, error:"${err}")
600
mesg_from_client_total.labels("#{mesg.event}").inc(1)
601
else
602
@push_to_client(message.error(error:"Hub does not know how to handle a '#{mesg.event}' event.", id:mesg.id))
603
if mesg.event == 'get_all_activity'
604
dbg("ignoring all further messages from old client=#{@id}")
605
@_ignore_client = true
606
607
mesg_ping: (mesg) =>
608
@push_to_client(message.pong(id:mesg.id, now:new Date()))
609
610
mesg_sign_in: (mesg) =>
611
sign_in.sign_in
612
client : @
613
mesg : mesg
614
logger : @logger
615
database : @database
616
host : @_opts.host
617
port : @_opts.port
618
619
mesg_sign_out: (mesg) =>
620
if not @account_id?
621
@push_to_client(message.error(id:mesg.id, error:"not signed in"))
622
return
623
624
if mesg.everywhere
625
# invalidate all remember_me cookies
626
@database.invalidate_all_remember_me
627
account_id : @account_id
628
@signed_out() # deletes @account_id... so must be below database call above
629
# invalidate the remember_me on this browser
630
@invalidate_remember_me
631
cb:(error) =>
632
@dbg('mesg_sign_out')("signing out: #{mesg.id}, #{error}")
633
if error
634
@push_to_client(message.error(id:mesg.id, error:error))
635
else
636
@push_to_client(message.signed_out(id:mesg.id))
637
638
# Messages: Password/email address management
639
mesg_change_email_address: (mesg) =>
640
try
641
await setEmailAddress
642
account_id: @account_id
643
email_address: mesg.new_email_address
644
password: mesg.password
645
@push_to_client(message.changed_email_address(id:mesg.id))
646
catch err
647
@error_to_client(id:mesg.id, error:err)
648
649
mesg_send_verification_email: (mesg) =>
650
auth = require('./auth')
651
auth.verify_email_send_token
652
account_id : mesg.account_id
653
only_verify : mesg.only_verify ? true
654
database : @database
655
cb : (err) =>
656
if err
657
@error_to_client(id:mesg.id, error:err)
658
else
659
@success_to_client(id:mesg.id)
660
661
mesg_unlink_passport: (mesg) =>
662
if not @account_id?
663
@error_to_client(id:mesg.id, error:"must be logged in")
664
else
665
opts =
666
account_id : @account_id
667
strategy : mesg.strategy
668
id : mesg.id
669
cb : (err) =>
670
if err
671
@error_to_client(id:mesg.id, error:err)
672
else
673
@success_to_client(id:mesg.id)
674
delete_passport(@database, opts)
675
676
# Messages: Account settings
677
get_groups: (cb) =>
678
# see note above about our "infinite caching". Maybe a bad idea.
679
if @groups?
680
cb?(undefined, @groups)
681
return
682
@database.get_account
683
columns : ['groups']
684
account_id : @account_id
685
cb : (err, r) =>
686
if err
687
cb?(err)
688
else
689
@groups = r['groups']
690
cb?(undefined, @groups)
691
692
# Messages: Log errors that client sees so we can also look at them
693
mesg_log_client_error: (mesg) =>
694
@dbg('mesg_log_client_error')(mesg.error)
695
if not mesg.type?
696
mesg.type = "error"
697
if not mesg.error?
698
mesg.error = "error"
699
@database.log_client_error
700
event : mesg.type
701
error : mesg.error
702
account_id : @account_id
703
cb : (err) =>
704
if not mesg.id?
705
return
706
if err
707
@error_to_client(id:mesg.id, error:err)
708
else
709
@success_to_client(id:mesg.id)
710
711
mesg_webapp_error: (mesg) =>
712
# Tell client we got it, thanks:
713
@success_to_client(id:mesg.id)
714
# Now do something with it.
715
@dbg('mesg_webapp_error')(mesg.msg)
716
mesg = misc.copy_without(mesg, 'event')
717
mesg.account_id = @account_id
718
@database.webapp_error(mesg)
719
720
# Messages: Project Management
721
get_project: (mesg, permission, cb) =>
722
###
723
How to use this: Either call the callback with the project, or if an error err
724
occured, call @error_to_client(id:mesg.id, error:err) and *NEVER*
725
call the callback. This function is meant to be used in a bunch
726
of the functions below for handling requests.
727
728
mesg -- must have project_id field
729
permission -- must be "read" or "write"
730
cb(err, project)
731
*NOTE*: on failure, if mesg.id is defined, then client will receive
732
an error message; the function calling get_project does *NOT*
733
have to send the error message back to the client!
734
###
735
dbg = @dbg('get_project')
736
737
err = undefined
738
if not mesg.project_id?
739
err = "mesg must have project_id attribute -- #{to_safe_str(mesg)}"
740
else if not @account_id?
741
err = "user must be signed in before accessing projects"
742
743
if err
744
if mesg.id?
745
@error_to_client(id:mesg.id, error:err)
746
cb(err)
747
return
748
749
key = mesg.project_id + permission
750
project = @_project_cache?[key]
751
if project?
752
# Use the cached project so we don't have to re-verify authentication
753
# for the user again below, which
754
# is very expensive. This cache does expire, in case user
755
# is kicked out of the project.
756
cb(undefined, project)
757
return
758
759
dbg()
760
async.series([
761
(cb) =>
762
switch permission
763
when 'read'
764
access.user_has_read_access_to_project
765
project_id : mesg.project_id
766
account_id : @account_id
767
account_groups : @groups
768
database : @database
769
cb : (err, result) =>
770
if err
771
cb("Read access denied -- #{err}")
772
else if not result
773
cb("User #{@account_id} does not have read access to project #{mesg.project_id}")
774
else
775
# good to go
776
cb()
777
when 'write'
778
access.user_has_write_access_to_project
779
database : @database
780
project_id : mesg.project_id
781
account_groups : @groups
782
account_id : @account_id
783
cb : (err, result) =>
784
if err
785
cb("Write access denied -- #{err}")
786
else if not result
787
cb("User #{@account_id} does not have write access to project #{mesg.project_id}")
788
else
789
# good to go
790
cb()
791
else
792
cb("Internal error -- unknown permission type '#{permission}'")
793
], (err) =>
794
if err
795
if mesg.id?
796
@error_to_client(id:mesg.id, error:err)
797
dbg("error -- #{err}")
798
cb(err)
799
else
800
project = hub_projects.new_project(mesg.project_id, @database, @projectControl)
801
@database.touch_project(project_id:mesg.project_id)
802
@_project_cache ?= {}
803
@_project_cache[key] = project
804
# cache for a while
805
setTimeout((()=>delete @_project_cache?[key]), CACHE_PROJECT_AUTH_MS)
806
dbg("got project; caching and returning")
807
cb(undefined, project)
808
)
809
810
mesg_create_project: (mesg) =>
811
if not @account_id?
812
@error_to_client(id: mesg.id, error: "You must be signed in to create a new project.")
813
return
814
@touch()
815
816
dbg = @dbg('mesg_create_project')
817
818
project_id = undefined
819
project = undefined
820
821
async.series([
822
(cb) =>
823
dbg("create project entry in database")
824
try
825
opts =
826
account_id : @account_id
827
title : mesg.title
828
description : mesg.description
829
image : mesg.image
830
license : mesg.license
831
noPool : mesg.noPool
832
project_id = await create_project(opts)
833
cb(undefined)
834
catch err
835
cb(err)
836
(cb) =>
837
cb() # we don't need to wait for project to start running before responding to user that project was created.
838
dbg("open project...")
839
# We do the open/start below so that when user tries to open it in a moment it opens more quickly;
840
# also, in single dev mode, this ensures that project path is created, so can copy
841
# files to the project, etc.
842
# Also, if mesg.start is set, the project gets started below.
843
try
844
project = await @projectControl(project_id)
845
await project.state(force:true, update:true)
846
if mesg.start
847
await project.start()
848
await delay(5000) # just in case
849
await project.start()
850
else
851
dbg("not auto-starting the new project")
852
catch err
853
dbg("failed to start project running -- #{err}")
854
], (err) =>
855
if err
856
dbg("error; project #{project_id} -- #{err}")
857
@error_to_client(id: mesg.id, error: "Failed to create new project '#{mesg.title}' -- #{misc.to_json(err)}")
858
else
859
dbg("SUCCESS: project #{project_id}")
860
@push_to_client(message.project_created(id:mesg.id, project_id:project_id))
861
# As an optimization, we start the process of opening the project, since the user is likely
862
# to open the project soon anyways.
863
dbg("start process of opening project")
864
@get_project {project_id:project_id}, 'write', (err, project) =>
865
)
866
867
mesg_write_text_file_to_project: (mesg) =>
868
@get_project mesg, 'write', (err, project) =>
869
if err
870
return
871
project.write_file
872
path : mesg.path
873
data : mesg.content
874
cb : (err) =>
875
if err
876
@error_to_client(id:mesg.id, error:err)
877
else
878
@push_to_client(message.file_written_to_project(id:mesg.id))
879
880
mesg_read_text_files_from_projects: (mesg) =>
881
if not misc.is_array(mesg.project_id)
882
@error_to_client(id:mesg.id, error:"project_id must be an array")
883
return
884
if not misc.is_array(mesg.path) or mesg.path.length != mesg.project_id.length
885
@error_to_client(id:mesg.id, error:"if project_id is an array, then path must be an array of the same length")
886
return
887
v = []
888
f = (mesg, cb) =>
889
@get_project mesg, 'read', (err, project) =>
890
if err
891
cb(err)
892
return
893
project.read_file
894
path : mesg.path
895
cb : (err, content) =>
896
if err
897
v.push({path:mesg.path, project_id:mesg.project_id, error:err})
898
else
899
v.push({path:mesg.path, project_id:mesg.project_id, content:content.blob.toString()})
900
cb()
901
paths = []
902
for i in [0...mesg.project_id.length]
903
paths.push({id:mesg.id, path:mesg.path[i], project_id:mesg.project_id[i]})
904
async.mapLimit paths, 20, f, (err) =>
905
if err
906
@error_to_client(id:mesg.id, error:err)
907
else
908
@push_to_client(message.text_file_read_from_project(id:mesg.id, content:v))
909
910
mesg_read_text_file_from_project: (mesg) =>
911
if misc.is_array(mesg.project_id)
912
@mesg_read_text_files_from_projects(mesg)
913
return
914
@get_project mesg, 'read', (err, project) =>
915
if err
916
return
917
project.read_file
918
path : mesg.path
919
cb : (err, content) =>
920
if err
921
@error_to_client(id:mesg.id, error:err)
922
else
923
t = content.blob.toString()
924
@push_to_client(message.text_file_read_from_project(id:mesg.id, content:t))
925
926
mesg_project_exec: (mesg) =>
927
if mesg.command == "ipython-notebook"
928
# we just drop these messages, which are from old non-updated clients (since we haven't
929
# written code yet to not allow them to connect -- TODO!).
930
return
931
@get_project mesg, 'write', (err, project) =>
932
if err
933
return
934
project.call
935
mesg : mesg
936
timeout : mesg.timeout
937
cb : (err, resp) =>
938
if err
939
@error_to_client(id:mesg.id, error:err)
940
else
941
@push_to_client(resp)
942
943
mesg_copy_path_between_projects: (mesg) =>
944
@copy_path.copy(mesg)
945
946
mesg_copy_path_status: (mesg) =>
947
@copy_path.status(mesg)
948
949
mesg_copy_path_delete: (mesg) =>
950
@copy_path.delete(mesg)
951
952
mesg_local_hub: (mesg) =>
953
###
954
Directly communicate with the local hub. If the
955
client has write access to the local hub, there's no
956
reason they shouldn't be allowed to send arbitrary
957
messages directly (they could anyways from the terminal).
958
###
959
dbg = @dbg('mesg_local_hub')
960
dbg("hub --> local_hub: ", mesg)
961
@get_project mesg, 'write', (err, project) =>
962
if err
963
return
964
if not mesg.message?
965
# in case the message itself is invalid -- is possible
966
@error_to_client(id:mesg.id, error:"message must be defined")
967
return
968
969
if mesg.message.event == 'project_exec' and mesg.message.command == "ipython-notebook"
970
# we just drop these messages, which are from old non-updated clients (since we haven't
971
# written code yet to not allow them to connect -- TODO!).
972
return
973
974
# It's extremely useful if the local hub has a way to distinguish between different clients who are
975
# being proxied through the same hub.
976
mesg.message.client_id = @id
977
978
# Make the actual call
979
project.call
980
mesg : mesg.message
981
timeout : mesg.timeout
982
multi_response : mesg.multi_response
983
cb : (err, resp) =>
984
if err
985
dbg("ERROR: #{err} calling message #{misc.to_json(mesg.message)}")
986
@error_to_client(id:mesg.id, error:err)
987
else
988
if not mesg.multi_response
989
resp.id = mesg.id
990
@push_to_client(resp)
991
992
mesg_user_search: (mesg) =>
993
if not @account_id?
994
@push_to_client(message.error(id:mesg.id, error:"You must be signed in to search for users."))
995
return
996
997
if not mesg.admin and (not mesg.limit? or mesg.limit > 50)
998
# hard cap at 50... (for non-admin)
999
mesg.limit = 50
1000
locals = {results: undefined}
1001
async.series([
1002
(cb) =>
1003
if mesg.admin
1004
@assert_user_is_in_group('admin', cb)
1005
else
1006
cb()
1007
(cb) =>
1008
@touch()
1009
opts =
1010
query : mesg.query
1011
limit : mesg.limit
1012
admin : mesg.admin
1013
active : mesg.active
1014
only_email: mesg.only_email
1015
try
1016
locals.results = await user_search(opts)
1017
cb(undefined)
1018
catch err
1019
cb(err)
1020
], (err) =>
1021
if err
1022
@error_to_client(id:mesg.id, error:err)
1023
else
1024
@push_to_client(message.user_search_results(id:mesg.id, results:locals.results))
1025
)
1026
1027
1028
# this is an async function
1029
allow_urls_in_emails: (project_id) =>
1030
is_paying = await is_paying_customer(@database, @account_id)
1031
has_network = await project_has_network_access(@database, project_id)
1032
return is_paying or has_network
1033
1034
mesg_invite_collaborator: (mesg) =>
1035
@touch()
1036
dbg = @dbg('mesg_invite_collaborator')
1037
#dbg("mesg: #{misc.to_json(mesg)}")
1038
@get_project mesg, 'write', (err, project) =>
1039
if err
1040
return
1041
locals =
1042
email_address : undefined
1043
done : false
1044
settings : undefined
1045
1046
# SECURITY NOTE: mesg.project_id is valid and the client has write access, since otherwise,
1047
# the @get_project function above wouldn't have returned without err...
1048
async.series([
1049
(cb) =>
1050
@database.add_user_to_project
1051
project_id : mesg.project_id
1052
account_id : mesg.account_id
1053
group : 'collaborator' # in future will be "invite_collaborator", once implemented
1054
cb : cb
1055
1056
(cb) =>
1057
# only send an email when there is an mesg.email body to send.
1058
# we want to make it explicit when they're sent, and implicitly disable it for API usage.
1059
if not mesg.email?
1060
locals.done = true
1061
cb()
1062
1063
(cb) =>
1064
if locals.done
1065
cb(); return
1066
1067
@database._query
1068
query : "SELECT email_address FROM accounts"
1069
where : "account_id = $::UUID" : mesg.account_id
1070
cb : one_result 'email_address', (err, x) =>
1071
locals.email_address = x
1072
cb(err)
1073
1074
(cb) =>
1075
if (not locals.email_address) or locals.done
1076
cb(); return
1077
1078
# INFO: for testing this, you have to reset the invite field each time you sent yourself an invitation
1079
# in psql: UPDATE projects SET invite = NULL WHERE project_id = '<UUID of your cc-in-cc dev project>';
1080
@database.when_sent_project_invite
1081
project_id : mesg.project_id
1082
to : locals.email_address
1083
cb : (err, when_sent) =>
1084
#console.log("mesg_invite_collaborator email #{locals.email_address}, #{err}, #{when_sent}")
1085
if err
1086
cb(err)
1087
else if when_sent >= misc.days_ago(7) # successfully sent < one week ago -- don't again
1088
locals.done = true
1089
cb()
1090
else
1091
cb()
1092
1093
(cb) =>
1094
if locals.done or (not locals.email_address)
1095
cb()
1096
return
1097
@database.get_server_settings_cached
1098
cb : (err, settings) =>
1099
if err
1100
cb(err)
1101
else if not settings?
1102
cb("no server settings -- no database connection?")
1103
else
1104
locals.settings = settings
1105
cb()
1106
1107
(cb) =>
1108
if locals.done or (not locals.email_address)
1109
dbg("NOT send_email invite to #{locals.email_address}")
1110
cb()
1111
return
1112
1113
## do not send email if project doesn't have network access (and user is not a paying customer)
1114
#if (not await is_paying_customer(@database, @account_id) and not await project_has_network_access(@database, mesg.project_id))
1115
# dbg("NOT send_email invite to #{locals.email_address} -- due to project lacking network access (and user not a customer)")
1116
# return
1117
1118
# we always send invite emails. for non-upgraded projects, we sanitize the content of the body
1119
# ATTN: this must harmonize with @cocalc/frontend/projects → allow_urls_in_emails
1120
# also see mesg_invite_noncloud_collaborators
1121
1122
dbg("send_email invite to #{locals.email_address}")
1123
# available message fields
1124
# mesg.title - title of project
1125
# mesg.link2proj
1126
# mesg.replyto
1127
# mesg.replyto_name
1128
# mesg.email - body of email
1129
# mesg.subject
1130
1131
# send an email to the user -- async, not blocking user.
1132
# TODO: this can take a while -- we need to take some action
1133
# if it fails, e.g., change a setting in the projects table!
1134
if mesg.replyto_name?
1135
subject = "#{mesg.replyto_name} invited you to collaborate on CoCalc in project '#{mesg.title}'"
1136
else
1137
subject = "Invitation to CoCalc for collaborating in project '#{mesg.title}'"
1138
# override subject if explicitly given
1139
if mesg.subject?
1140
subject = mesg.subject
1141
1142
send_invite_email
1143
to : locals.email_address
1144
subject : subject
1145
email : mesg.email
1146
email_address : locals.email_address
1147
title : mesg.title
1148
allow_urls : await @allow_urls_in_emails(mesg.project_id)
1149
replyto : mesg.replyto ? locals.settings.organization_email
1150
replyto_name : mesg.replyto_name
1151
link2proj : mesg.link2proj
1152
settings : locals.settings
1153
cb : (err) =>
1154
if err
1155
dbg("FAILED to send email to #{locals.email_address} -- err=#{misc.to_json(err)}")
1156
@database.sent_project_invite
1157
project_id : mesg.project_id
1158
to : locals.email_address
1159
error : err
1160
cb(err) # call the cb one scope up so that the client is informed that we sent the invite (or not)
1161
1162
], (err) =>
1163
if err
1164
@error_to_client(id:mesg.id, error:err)
1165
else
1166
@push_to_client(message.success(id:mesg.id))
1167
)
1168
1169
mesg_add_license_to_project: (mesg) =>
1170
dbg = @dbg('mesg_add_license_to_project')
1171
dbg()
1172
@touch()
1173
@_check_project_access mesg.project_id, (err) =>
1174
if err
1175
dbg("failed -- #{err}")
1176
@error_to_client(id:mesg.id, error:"must have write access to #{mesg.project_id} -- #{err}")
1177
return
1178
try
1179
await addLicenseToProject({project_id:mesg.project_id, license_id:mesg.license_id})
1180
@success_to_client(id:mesg.id)
1181
catch err
1182
@error_to_client(id:mesg.id, error:"#{err}")
1183
1184
mesg_remove_license_from_project: (mesg) =>
1185
dbg = @dbg('mesg_remove_license_from_project')
1186
dbg()
1187
@touch()
1188
@_check_project_access mesg.project_id, (err) =>
1189
if err
1190
dbg("failed -- #{err}")
1191
@error_to_client(id:mesg.id, error:"must have write access to #{mesg.project_id} -- #{err}")
1192
return
1193
try
1194
await removeLicenseFromProject({project_id:mesg.project_id, license_id:mesg.license_id})
1195
@success_to_client(id:mesg.id)
1196
catch err
1197
@error_to_client(id:mesg.id, error:"#{err}")
1198
1199
mesg_invite_noncloud_collaborators: (mesg) =>
1200
dbg = @dbg('mesg_invite_noncloud_collaborators')
1201
1202
#
1203
# Uncomment this in case of attack by evil forces:
1204
#
1205
## @error_to_client(id:mesg.id, error:"inviting collaborators who do not already have a cocalc account to projects is currently disabled due to abuse");
1206
## return
1207
1208
# Otherwise we always allow sending email invites
1209
# The body is sanitized and not allowed to contain any URLs (anti-spam), unless
1210
# (a) the sender is a paying customer or (b) the project has network access.
1211
#
1212
# ATTN: this must harmonize with @cocalc/frontend/projects → allow_urls_in_emails
1213
# also see mesg_invite_collaborator
1214
1215
@touch()
1216
@get_project mesg, 'write', (err, project) =>
1217
if err
1218
return
1219
1220
if mesg.to.length > 1024
1221
@error_to_client(id:mesg.id, error:"Specify less recipients when adding collaborators to project.")
1222
return
1223
1224
# users to invite
1225
to = (x for x in mesg.to.replace(/\s/g,",").replace(/;/g,",").split(',') when x)
1226
1227
invite_user = (email_address, cb) =>
1228
dbg("inviting #{email_address}")
1229
if not misc.is_valid_email_address(email_address)
1230
cb("invalid email address '#{email_address}'")
1231
return
1232
email_address = misc.lower_email_address(email_address)
1233
if email_address.length >= 128
1234
# if an attacker tries to embed a spam in the email address itself (e.g, [email protected]), then
1235
# at least we can limit its size.
1236
cb("email address must be at most 128 characters: '#{email_address}'")
1237
return
1238
1239
locals =
1240
done : false
1241
account_id : undefined
1242
settings : undefined
1243
1244
async.series([
1245
# already have an account?
1246
(cb) =>
1247
@database.account_exists
1248
email_address : email_address
1249
cb : (err, _account_id) =>
1250
dbg("account_exists: #{err}, #{_account_id}")
1251
locals.account_id = _account_id
1252
cb(err)
1253
(cb) =>
1254
if locals.account_id
1255
dbg("user #{email_address} already has an account -- add directly")
1256
# user has an account already
1257
locals.done = true
1258
@database.add_user_to_project
1259
project_id : mesg.project_id
1260
account_id : locals.account_id
1261
group : 'collaborator'
1262
cb : cb
1263
else
1264
dbg("user #{email_address} doesn't have an account yet -- may send email (if we haven't recently)")
1265
# create trigger so that when user eventually makes an account,
1266
# they will be added to the project.
1267
@database.account_creation_actions
1268
email_address : email_address
1269
action : {action:'add_to_project', group:'collaborator', project_id:mesg.project_id}
1270
ttl : 60*60*24*14 # valid for 14 days
1271
cb : cb
1272
(cb) =>
1273
if locals.done
1274
cb()
1275
else
1276
@database.when_sent_project_invite
1277
project_id : mesg.project_id
1278
to : email_address
1279
cb : (err, when_sent) =>
1280
if err
1281
cb(err)
1282
else if when_sent >= misc.days_ago(RESEND_INVITE_INTERVAL_DAYS) # successfully sent this long ago -- don't again
1283
locals.done = true
1284
cb()
1285
else
1286
cb()
1287
1288
(cb) =>
1289
if locals.done
1290
cb()
1291
return
1292
@database.get_server_settings_cached
1293
cb: (err, settings) =>
1294
if err
1295
cb(err)
1296
else if not settings?
1297
cb("no server settings -- no database connection?")
1298
else
1299
locals.settings = settings
1300
cb()
1301
1302
(cb) =>
1303
if locals.done
1304
dbg("NOT send_email invite to #{email_address}")
1305
cb()
1306
return
1307
1308
# send an email to the user -- async, not blocking user.
1309
# TODO: this can take a while -- we need to take some action
1310
# if it fails, e.g., change a setting in the projects table!
1311
subject = "CoCalc Invitation"
1312
# override subject if explicitly given
1313
if mesg.subject?
1314
subject = mesg.subject
1315
1316
dbg("send_email invite to #{email_address}")
1317
send_invite_email
1318
to : email_address
1319
subject : subject
1320
email : mesg.email
1321
email_address : email_address
1322
title : mesg.title
1323
allow_urls : await @allow_urls_in_emails(mesg.project_id)
1324
replyto : mesg.replyto ? locals.settings.organization_email
1325
replyto_name : mesg.replyto_name
1326
link2proj : mesg.link2proj
1327
settings : locals.settings
1328
cb : (err) =>
1329
if err
1330
dbg("FAILED to send email to #{email_address} -- err=#{misc.to_json(err)}")
1331
@database.sent_project_invite
1332
project_id : mesg.project_id
1333
to : email_address
1334
error : err
1335
cb(err) # call the cb one scope up so that the client is informed that we sent the invite (or not)
1336
1337
], cb)
1338
1339
async.map to, invite_user, (err, results) =>
1340
if err
1341
@error_to_client(id:mesg.id, error:err)
1342
else
1343
@push_to_client(message.invite_noncloud_collaborators_resp(id:mesg.id, mesg:"Invited #{mesg.to} to collaborate on a project."))
1344
1345
mesg_remove_collaborator: (mesg) =>
1346
@touch()
1347
@get_project mesg, 'write', (err, project) =>
1348
if err
1349
return
1350
# See "Security note" in mesg_invite_collaborator
1351
@database.remove_collaborator_from_project
1352
project_id : mesg.project_id
1353
account_id : mesg.account_id
1354
cb : (err) =>
1355
if err
1356
@error_to_client(id:mesg.id, error:err)
1357
else
1358
@push_to_client(message.success(id:mesg.id))
1359
1360
# NOTE: this is different than invite_collab, in that it is
1361
# much more similar to remove_collaborator. It also supports
1362
# adding multiple collabs to multiple projects (NOT in one
1363
# transaction, though).
1364
mesg_add_collaborator: (mesg) =>
1365
@touch()
1366
projects = mesg.project_id
1367
accounts = mesg.account_id
1368
tokens = mesg.token_id
1369
1370
is_single_token = false
1371
if tokens
1372
if not misc.is_array(tokens)
1373
is_single_token = true
1374
tokens = [tokens]
1375
projects = ('' for _ in [0...tokens.length]) # will get mutated below as tokens are used
1376
if not misc.is_array(projects)
1377
projects = [projects]
1378
if not misc.is_array(accounts)
1379
accounts = [accounts]
1380
1381
try
1382
await collab.add_collaborators_to_projects(@database, @account_id, accounts, projects, tokens)
1383
resp = message.success(id:mesg.id)
1384
if tokens
1385
# Tokens determine the projects, and it maybe useful to the client to know what
1386
# project they just got added to!
1387
if is_single_token
1388
resp.project_id = projects[0]
1389
else
1390
resp.project_id = projects
1391
@push_to_client(resp)
1392
catch err
1393
@error_to_client(id:mesg.id, error:"#{err}")
1394
1395
mesg_remove_blob_ttls: (mesg) =>
1396
if not @account_id?
1397
@push_to_client(message.error(id:mesg.id, error:"not yet signed in"))
1398
else
1399
@database.remove_blob_ttls
1400
uuids : mesg.uuids
1401
cb : (err) =>
1402
if err
1403
@error_to_client(id:mesg.id, error:err)
1404
else
1405
@push_to_client(message.success(id:mesg.id))
1406
1407
mesg_version: (mesg) =>
1408
# The version of the client...
1409
@smc_version = mesg.version
1410
@dbg('mesg_version')("client.smc_version=#{mesg.version}")
1411
{version} = await require('./servers/server-settings').default()
1412
if mesg.version < version.version_recommended_browser ? 0
1413
@push_version_update()
1414
1415
push_version_update: =>
1416
{version} = await require('./servers/server-settings').default()
1417
@push_to_client(message.version(version:version.version_recommended_browser, min_version:version.version_min_browser))
1418
if version.version_min_browser and @smc_version < version.version_min_browser
1419
# Client is running an unsupported bad old version.
1420
# Brutally disconnect client! It's critical that they upgrade, since they are
1421
# causing problems or have major buggy code.
1422
if new Date() - @_when_connected <= 30000
1423
# If they just connected, kill the connection instantly
1424
@conn.end()
1425
else
1426
# Wait 1 minute to give them a chance to save data...
1427
setTimeout((()=>@conn.end()), 60000)
1428
1429
_user_is_in_group: (group) =>
1430
return @groups? and group in @groups
1431
1432
assert_user_is_in_group: (group, cb) =>
1433
@get_groups (err) =>
1434
if not err and not @_user_is_in_group('admin') # user_is_in_group works after get_groups is called.
1435
err = "must be logged in and a member of the admin group"
1436
cb(err)
1437
1438
mesg_project_set_quotas: (mesg) =>
1439
if not misc.is_valid_uuid_string(mesg.project_id)
1440
@error_to_client(id:mesg.id, error:"invalid project_id")
1441
return
1442
project = undefined
1443
dbg = @dbg("mesg_project_set_quotas(project_id='#{mesg.project_id}')")
1444
async.series([
1445
(cb) =>
1446
@assert_user_is_in_group('admin', cb)
1447
(cb) =>
1448
dbg("update base quotas in the database")
1449
@database.set_project_settings
1450
project_id : mesg.project_id
1451
settings : misc.copy_without(mesg, ['event', 'id'])
1452
cb : cb
1453
(cb) =>
1454
dbg("get project from compute server")
1455
try
1456
project = await @projectControl(mesg.project_id)
1457
cb()
1458
catch err
1459
cb(err)
1460
(cb) =>
1461
dbg("determine total quotas and apply")
1462
try
1463
project.setAllQuotas()
1464
cb()
1465
catch err
1466
cb(err)
1467
], (err) =>
1468
if err
1469
@error_to_client(id:mesg.id, error:"problem setting project quota -- #{err}")
1470
else
1471
@push_to_client(message.success(id:mesg.id))
1472
)
1473
1474
###
1475
Public/published projects data
1476
###
1477
path_is_in_public_paths: (path, paths) =>
1478
return misc.path_is_in_public_paths(path, misc.keys(paths))
1479
1480
get_public_project: (opts) =>
1481
###
1482
Get a compute.Project object, or cb an error if the given
1483
path in the project isn't public. This is just like getting
1484
a project, but first ensures that given path is public.
1485
###
1486
opts = defaults opts,
1487
project_id : undefined
1488
path : undefined
1489
use_cache : true
1490
cb : required
1491
1492
if not opts.project_id?
1493
opts.cb("get_public_project: project_id must be defined")
1494
return
1495
1496
if not opts.path?
1497
opts.cb("get_public_project: path must be defined")
1498
return
1499
1500
# determine if path is public in given project, without using cache to determine paths; this *does* cache the result.
1501
@database.path_is_public
1502
project_id : opts.project_id
1503
path : opts.path
1504
cb : (err, is_public) =>
1505
if err
1506
opts.cb(err)
1507
return
1508
if is_public
1509
try
1510
opts.cb(undefined, await @projectControl(opts.project_id))
1511
catch err
1512
opts.cb(err)
1513
else
1514
# no
1515
opts.cb("path '#{opts.path}' of project with id '#{opts.project_id}' is not public")
1516
1517
mesg_copy_public_path_between_projects: (mesg) =>
1518
@touch()
1519
if not mesg.src_project_id?
1520
@error_to_client(id:mesg.id, error:"src_project_id must be defined")
1521
return
1522
if not mesg.target_project_id?
1523
@error_to_client(id:mesg.id, error:"target_project_id must be defined")
1524
return
1525
if not mesg.src_path?
1526
@error_to_client(id:mesg.id, error:"src_path must be defined")
1527
return
1528
project = undefined
1529
async.series([
1530
(cb) =>
1531
# ensure user can write to the target project
1532
access.user_has_write_access_to_project
1533
database : @database
1534
project_id : mesg.target_project_id
1535
account_id : @account_id
1536
account_groups : @groups
1537
cb : (err, result) =>
1538
if err
1539
cb(err)
1540
else if not result
1541
cb("user must have write access to target project #{mesg.target_project_id}")
1542
else
1543
cb()
1544
(cb) =>
1545
# Obviously, no need to check write access about the source project,
1546
# since we are only granting access to public files. This function
1547
# should ensure that the path is public:
1548
@get_public_project
1549
project_id : mesg.src_project_id
1550
path : mesg.src_path
1551
cb : (err, x) =>
1552
project = x
1553
cb(err)
1554
(cb) =>
1555
try
1556
await project.copyPath
1557
path : mesg.src_path
1558
target_project_id : mesg.target_project_id
1559
target_path : mesg.target_path
1560
overwrite_newer : mesg.overwrite_newer
1561
delete_missing : mesg.delete_missing
1562
timeout : mesg.timeout
1563
backup : mesg.backup
1564
public : true
1565
wait_until_done : true
1566
cb()
1567
catch err
1568
cb(err)
1569
], (err) =>
1570
if err
1571
@error_to_client(id:mesg.id, error:err)
1572
else
1573
@push_to_client(message.success(id:mesg.id))
1574
)
1575
1576
###
1577
Data Query
1578
###
1579
mesg_query: (mesg) =>
1580
dbg = @dbg("user_query")
1581
query = mesg.query
1582
if not query?
1583
@error_to_client(id:mesg.id, error:"malformed query")
1584
return
1585
# CRITICAL: don't enable this except for serious debugging, since it can result in HUGE output
1586
#dbg("account_id=#{@account_id} makes query='#{misc.to_json(query)}'")
1587
first = true
1588
if mesg.changes
1589
@_query_changefeeds ?= {}
1590
@_query_changefeeds[mesg.id] = true
1591
mesg_id = mesg.id
1592
@database.user_query
1593
client_id : @id
1594
account_id : @account_id
1595
query : query
1596
options : mesg.options
1597
changes : if mesg.changes then mesg_id
1598
cb : (err, result) =>
1599
if @closed # connection closed, so nothing further to do with this
1600
return
1601
if result?.action == 'close'
1602
err = 'close'
1603
if err
1604
dbg("user_query(query='#{misc.to_json(query)}') error:", err)
1605
if @_query_changefeeds?[mesg_id]
1606
delete @_query_changefeeds[mesg_id]
1607
@error_to_client(id:mesg_id, error:"#{err}") # Ensure err like Error('foo') can be JSON'd
1608
if mesg.changes and not first and @_query_changefeeds?[mesg_id]?
1609
dbg("changefeed got messed up, so cancel it:")
1610
@database.user_query_cancel_changefeed(id : mesg_id)
1611
else
1612
if mesg.changes and not first
1613
resp = result
1614
resp.id = mesg_id
1615
resp.multi_response = true
1616
else
1617
first = false
1618
resp = mesg
1619
resp.query = result
1620
@push_to_client(resp)
1621
1622
query_cancel_all_changefeeds: (cb) =>
1623
if not @_query_changefeeds?
1624
cb?(); return
1625
cnt = misc.len(@_query_changefeeds)
1626
if cnt == 0
1627
cb?(); return
1628
dbg = @dbg("query_cancel_all_changefeeds")
1629
v = @_query_changefeeds
1630
dbg("cancel #{cnt} changefeeds")
1631
delete @_query_changefeeds
1632
f = (id, cb) =>
1633
dbg("cancel id=#{id}")
1634
@database.user_query_cancel_changefeed
1635
id : id
1636
cb : (err) =>
1637
if err
1638
dbg("FEED: warning #{id} -- error canceling a changefeed #{misc.to_json(err)}")
1639
else
1640
dbg("FEED: canceled changefeed -- #{id}")
1641
cb()
1642
async.map(misc.keys(v), f, (err) => cb?(err))
1643
1644
mesg_query_cancel: (mesg) =>
1645
if not @_query_changefeeds?[mesg.id]?
1646
# no such changefeed
1647
@success_to_client(id:mesg.id)
1648
else
1649
# actualy cancel it.
1650
if @_query_changefeeds?
1651
delete @_query_changefeeds[mesg.id]
1652
@database.user_query_cancel_changefeed
1653
id : mesg.id
1654
cb : (err, resp) =>
1655
if err
1656
@error_to_client(id:mesg.id, error:err)
1657
else
1658
mesg.resp = resp
1659
@push_to_client(mesg)
1660
1661
1662
###
1663
Support Tickets → Zendesk
1664
###
1665
mesg_create_support_ticket: (mesg) =>
1666
dbg = @dbg("mesg_create_support_ticket")
1667
dbg('deprecated')
1668
@error_to_client(id:mesg.id, error:'deprecated')
1669
1670
mesg_get_support_tickets: (mesg) =>
1671
# retrieves the support tickets the user with the current account_id
1672
dbg = @dbg("mesg_get_support_tickets")
1673
dbg('deprecated')
1674
@error_to_client(id:mesg.id, error:'deprecated')
1675
1676
###
1677
Stripe-integration billing code
1678
###
1679
handle_stripe_mesg: (mesg) =>
1680
try
1681
await @_stripe_client ?= new StripeClient(@)
1682
catch err
1683
@error_to_client(id:mesg.id, error:"${err}")
1684
return
1685
@_stripe_client.handle_mesg(mesg)
1686
1687
mesg_stripe_get_customer: (mesg) =>
1688
@handle_stripe_mesg(mesg)
1689
1690
mesg_stripe_create_source: (mesg) =>
1691
@handle_stripe_mesg(mesg)
1692
1693
mesg_stripe_delete_source: (mesg) =>
1694
@handle_stripe_mesg(mesg)
1695
1696
mesg_stripe_set_default_source: (mesg) =>
1697
@handle_stripe_mesg(mesg)
1698
1699
mesg_stripe_update_source: (mesg) =>
1700
@handle_stripe_mesg(mesg)
1701
1702
mesg_stripe_create_subscription: (mesg) =>
1703
@handle_stripe_mesg(mesg)
1704
1705
mesg_stripe_cancel_subscription: (mesg) =>
1706
@handle_stripe_mesg(mesg)
1707
1708
mesg_stripe_update_subscription: (mesg) =>
1709
@handle_stripe_mesg(mesg)
1710
1711
mesg_stripe_get_subscriptions: (mesg) =>
1712
@handle_stripe_mesg(mesg)
1713
1714
mesg_stripe_get_coupon: (mesg) =>
1715
@handle_stripe_mesg(mesg)
1716
1717
mesg_stripe_get_charges: (mesg) =>
1718
@handle_stripe_mesg(mesg)
1719
1720
mesg_stripe_get_invoices: (mesg) =>
1721
@handle_stripe_mesg(mesg)
1722
1723
mesg_stripe_admin_create_invoice_item: (mesg) =>
1724
@handle_stripe_mesg(mesg)
1725
1726
mesg_get_available_upgrades: (mesg) =>
1727
mesg.event = 'stripe_get_available_upgrades' # for backward compat
1728
@handle_stripe_mesg(mesg)
1729
1730
mesg_remove_all_upgrades: (mesg) =>
1731
mesg.event = 'stripe_remove_all_upgrades' # for backward compat
1732
@handle_stripe_mesg(mesg)
1733
1734
mesg_purchase_license: (mesg) =>
1735
try
1736
await @_stripe_client ?= new StripeClient(@)
1737
resp = await purchase_license(@account_id, mesg.info)
1738
@push_to_client(message.purchase_license_resp(id:mesg.id, resp:resp))
1739
catch err
1740
@error_to_client(id:mesg.id, error:err.toString())
1741
1742
# END stripe-related functionality
1743
1744
mesg_api_key: (mesg) =>
1745
try
1746
api_key = await legacyManageApiKey
1747
account_id : @account_id
1748
password : mesg.password
1749
action : mesg.action
1750
if api_key
1751
@push_to_client(message.api_key_info(id:mesg.id, api_key:api_key))
1752
else
1753
@success_to_client(id:mesg.id)
1754
catch err
1755
@error_to_client(id:mesg.id, error:err)
1756
1757
mesg_api_keys: (mesg) =>
1758
try
1759
response = await manageApiKeys
1760
account_id : @account_id
1761
password : mesg.password
1762
action : mesg.action
1763
project_id : mesg.project_id
1764
id : mesg.key_id
1765
expire : mesg.expire
1766
name : mesg.name
1767
@push_to_client(message.api_keys_response(id:mesg.id, response:response))
1768
catch err
1769
@error_to_client(id:mesg.id, error:err)
1770
1771
mesg_user_auth: (mesg) =>
1772
auth_token.get_user_auth_token
1773
database : @database
1774
account_id : @account_id # strictly not necessary yet... but good if user has to be signed in,
1775
# since more secure and we can rate limit attempts from a given user.
1776
user_account_id : mesg.account_id
1777
password : mesg.password
1778
cb : (err, auth_token) =>
1779
if err
1780
@error_to_client(id:mesg.id, error:err)
1781
else
1782
@push_to_client(message.user_auth_token(id:mesg.id, auth_token:auth_token))
1783
1784
mesg_revoke_auth_token: (mesg) =>
1785
auth_token.revoke_user_auth_token
1786
database : @database
1787
auth_token : mesg.auth_token
1788
cb : (err) =>
1789
if err
1790
@error_to_client(id:mesg.id, error:err)
1791
else
1792
@push_to_client(message.success(id:mesg.id))
1793
1794
# Receive and store in memory the latest metrics status from the client.
1795
mesg_metrics: (mesg) =>
1796
dbg = @dbg('mesg_metrics')
1797
dbg()
1798
if not mesg?.metrics
1799
return
1800
metrics = mesg.metrics
1801
#dbg('GOT: ', misc.to_json(metrics))
1802
if not misc.is_array(metrics)
1803
# client is messing with us...?
1804
return
1805
for metric in metrics
1806
if not misc.is_array(metric?.values)
1807
# what?
1808
return
1809
if metric.values.length == 0
1810
return
1811
for v in metric.values
1812
if not misc.is_object(v?.labels)
1813
# what?
1814
return
1815
switch metric.type
1816
when 'gauge'
1817
metric.aggregator = 'average'
1818
else
1819
metric.aggregator = 'sum'
1820
1821
client_metrics[@id] = metrics
1822
#dbg('RECORDED: ', misc.to_json(client_metrics[@id]))
1823
1824
_check_project_access: (project_id, cb) =>
1825
if not @account_id?
1826
cb('you must be signed in to access project')
1827
return
1828
if not misc.is_valid_uuid_string(project_id)
1829
cb('project_id must be specified and valid')
1830
return
1831
access.user_has_write_access_to_project
1832
database : @database
1833
project_id : project_id
1834
account_groups : @groups
1835
account_id : @account_id
1836
cb : (err, result) =>
1837
if err
1838
cb(err)
1839
else if not result
1840
cb("must have write access")
1841
else
1842
cb()
1843
1844
_check_syncdoc_access: (string_id, cb) =>
1845
if not @account_id?
1846
cb('you must be signed in to access syncdoc')
1847
return
1848
if not typeof string_id == 'string' and string_id.length == 40
1849
cb('string_id must be specified and valid')
1850
return
1851
@database._query
1852
query : "SELECT project_id FROM syncstrings"
1853
where : {"string_id = $::CHAR(40)" : string_id}
1854
cb : (err, results) =>
1855
if err
1856
cb(err)
1857
else if results.rows.length != 1
1858
cb("no such syncdoc")
1859
else
1860
project_id = results.rows[0].project_id
1861
@_check_project_access(project_id, cb)
1862
1863
mesg_disconnect_from_project: (mesg) =>
1864
dbg = @dbg('mesg_disconnect_from_project')
1865
@_check_project_access mesg.project_id, (err) =>
1866
if err
1867
dbg("failed -- #{err}")
1868
@error_to_client(id:mesg.id, error:"unable to disconnect from project #{mesg.project_id} -- #{err}")
1869
else
1870
local_hub_connection.disconnect_from_project(mesg.project_id)
1871
@push_to_client(message.success(id:mesg.id))
1872
1873
mesg_touch_project: (mesg) =>
1874
dbg = @dbg('mesg_touch_project')
1875
async.series([
1876
(cb) =>
1877
dbg("checking conditions")
1878
@_check_project_access(mesg.project_id, cb)
1879
(cb) =>
1880
# IMPORTANT: do this ensure_connection_to_project *first*, since
1881
# it is critical always ensure this, and the @touch below gives
1882
# an error if done more than once per 45s, whereas we may want
1883
# to check much more frequently that we have a TCP connection
1884
# to the project.
1885
f = @database.ensure_connection_to_project
1886
if f?
1887
dbg("also create socket connection (so project can query db, etc.)")
1888
# We do NOT block on this -- it can take a while.
1889
f(mesg.project_id)
1890
cb()
1891
(cb) =>
1892
@touch
1893
project_id : mesg.project_id
1894
action : 'touch'
1895
cb : cb
1896
], (err) =>
1897
if err
1898
dbg("failed -- #{err}")
1899
@error_to_client(id:mesg.id, error:"unable to touch project #{mesg.project_id} -- #{err}")
1900
else
1901
@push_to_client(message.success(id:mesg.id))
1902
)
1903
1904
mesg_get_syncdoc_history: (mesg) =>
1905
dbg = @dbg('mesg_syncdoc_history')
1906
try
1907
dbg("checking conditions")
1908
# this raises an error if user does not have access
1909
await callback(@_check_syncdoc_access, mesg.string_id)
1910
# get the history
1911
history = await @database.syncdoc_history_async(mesg.string_id, mesg.patches)
1912
dbg("success!")
1913
@push_to_client(message.syncdoc_history(id:mesg.id, history:history))
1914
catch err
1915
dbg("failed -- #{err}")
1916
@error_to_client(id:mesg.id, error:"unable to get syncdoc history for string_id #{mesg.string_id} -- #{err}")
1917
1918
mesg_user_tracking: (mesg) =>
1919
dbg = @dbg("mesg_user_tracking")
1920
try
1921
if not @account_id
1922
throw Error("you must be signed in to record a tracking event")
1923
await record_user_tracking(@database, @account_id, mesg.evt, mesg.value)
1924
@push_to_client(message.success(id:mesg.id))
1925
catch err
1926
dbg("failed -- #{err}")
1927
@error_to_client(id:mesg.id, error:"unable to record user_tracking event #{mesg.evt} -- #{err}")
1928
1929
mesg_admin_reset_password: (mesg) =>
1930
dbg = @dbg("mesg_reset_password")
1931
dbg(mesg.email_address)
1932
try
1933
if not misc.is_valid_email_address(mesg.email_address)
1934
throw Error("invalid email address")
1935
await callback(@assert_user_is_in_group, 'admin')
1936
if not await callback2(@database.account_exists, {email_address : mesg.email_address})
1937
throw Error("no such account with email #{mesg.email_address}")
1938
# We now know that there is an account with this email address.
1939
# put entry in the password_reset uuid:value table with ttl of 24 hours.
1940
# NOTE: when users request their own reset, the ttl is 1 hour, but when we
1941
# as admins send one manually, they typically need more time, so 1 day instead.
1942
# We used 8 hours for a while and it is often not enough time.
1943
id = await callback2(@database.set_password_reset, {email_address : mesg.email_address, ttl:24*60*60});
1944
mesg.link = "/auth/password-reset/#{id}"
1945
@push_to_client(mesg)
1946
catch err
1947
dbg("failed -- #{err}")
1948
@error_to_client(id:mesg.id, error:"#{err}")
1949
1950
mesg_chatgpt: (mesg) =>
1951
dbg = @dbg("mesg_chatgpt")
1952
dbg(mesg.text)
1953
if not @account_id?
1954
@error_to_client(id:mesg.id, error:"not signed in")
1955
return
1956
if mesg.stream
1957
try
1958
stream = (text) =>
1959
@push_to_client(message.chatgpt_response(id:mesg.id, text:text, multi_response:text?))
1960
await llm.evaluate(input:mesg.text, system:mesg.system, account_id:@account_id, project_id:mesg.project_id, path:mesg.path, history:mesg.history, model:mesg.model, tag:mesg.tag, stream:stream)
1961
catch err
1962
dbg("failed -- #{err}")
1963
@error_to_client(id:mesg.id, error:"#{err}")
1964
else
1965
try
1966
output = await llm.evaluate(input:mesg.text, system:mesg.system, account_id:@account_id, project_id:mesg.project_id, path:mesg.path, history:mesg.history, model:mesg.model, tag:mesg.tag)
1967
@push_to_client(message.chatgpt_response(id:mesg.id, text:output))
1968
catch err
1969
dbg("failed -- #{err}")
1970
@error_to_client(id:mesg.id, error:"#{err}")
1971
1972
# These are deprecated. Not the best approach.
1973
mesg_openai_embeddings_search: (mesg) =>
1974
@error_to_client(id:mesg.id, error:"openai_embeddings_search is DEPRECATED")
1975
1976
mesg_openai_embeddings_save: (mesg) =>
1977
@error_to_client(id:mesg.id, error:"openai_embeddings_save is DEPRECATED")
1978
1979
mesg_openai_embeddings_remove: (mesg) =>
1980
@error_to_client(id:mesg.id, error:"openai_embeddings_remove is DEPRECATED")
1981
1982
mesg_jupyter_execute: (mesg) =>
1983
dbg = @dbg("mesg_jupyter_execute")
1984
dbg(mesg.text)
1985
if not @account_id?
1986
@error_to_client(id:mesg.id, error:"not signed in")
1987
return
1988
try
1989
resp = await jupyter_execute(mesg)
1990
resp.id = mesg.id
1991
@push_to_client(message.jupyter_execute_response(resp))
1992
catch err
1993
dbg("failed -- #{err}")
1994
@error_to_client(id:mesg.id, error:"#{err}")
1995
1996
mesg_jupyter_kernels: (mesg) =>
1997
dbg = @dbg("mesg_jupyter_kernels")
1998
dbg(mesg.text)
1999
try
2000
@push_to_client(message.jupyter_kernels(id:mesg.id, kernels:await jupyter_kernels({project_id:mesg.project_id, account_id:@account_id})))
2001
catch err
2002
dbg("failed -- #{err}")
2003
@error_to_client(id:mesg.id, error:"#{err}")
2004
2005
2006