Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/hub/client.coffee
Views: 687
#########################################################################1# This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2# License: MS-RSL – see LICENSE.md for details3#########################################################################45###6Client = a client that is connected via a persistent connection to the hub7###89{EventEmitter} = require('events')10uuid = require('uuid')11async = require('async')12Cookies = require('cookies') # https://github.com/jed/cookies13misc = require('@cocalc/util/misc')14{defaults, required, to_safe_str} = misc15message = require('@cocalc/util/message')16access = require('./access')17clients = require('./clients').getClients()18auth = require('./auth')19auth_token = require('./auth-token')20local_hub_connection = require('./local_hub_connection')21sign_in = require('@cocalc/server/hub/sign-in')22hub_projects = require('./projects')23{StripeClient} = require('@cocalc/server/stripe/client')24{send_email, send_invite_email} = require('./email')25manageApiKeys = require("@cocalc/server/api/manage").default26{legacyManageApiKey} = require("@cocalc/server/api/manage")27purchase_license = require('@cocalc/server/licenses/purchase').default28db_schema = require('@cocalc/util/db-schema')29{ escapeHtml } = require("escape-html")30{CopyPath} = require('./copy-path')31{ REMEMBER_ME_COOKIE_NAME } = require("@cocalc/backend/auth/cookie-names");32generateHash = require("@cocalc/server/auth/hash").default;33passwordHash = require("@cocalc/backend/auth/password-hash").default;34llm = require('@cocalc/server/llm/index');35jupyter_execute = require('@cocalc/server/jupyter/execute').execute;36jupyter_kernels = require('@cocalc/server/jupyter/kernels').default;37create_project = require("@cocalc/server/projects/create").default;38user_search = require("@cocalc/server/accounts/search").default;39collab = require('@cocalc/server/projects/collab');40delete_passport = require('@cocalc/server/auth/sso/delete-passport').delete_passport;41setEmailAddress = require("@cocalc/server/accounts/set-email-address").default;4243{one_result} = require("@cocalc/database")4445path_join = require('path').join46base_path = require('@cocalc/backend/base-path').default4748underscore = require('underscore')4950{callback, delay} = require('awaiting')51{callback2} = require('@cocalc/util/async-utils')5253{record_user_tracking} = require('@cocalc/database/postgres/user-tracking')54{project_has_network_access} = require('@cocalc/database/postgres/project-queries')55{is_paying_customer} = require('@cocalc/database/postgres/account-queries')56{get_personal_user} = require('@cocalc/database/postgres/personal')5758{RESEND_INVITE_INTERVAL_DAYS} = require("@cocalc/util/consts/invites")5960removeLicenseFromProject = require('@cocalc/server/licenses/remove-from-project').default61addLicenseToProject = require('@cocalc/server/licenses/add-to-project').default6263DEBUG2 = !!process.env.SMC_DEBUG26465REQUIRE_ACCOUNT_TO_EXECUTE_CODE = false6667# Temporarily to handle old clients for a few days.68JSON_CHANNEL = '\u0000'6970# Anti DOS parameters:71# If a client sends a burst of messages, we space handling them out by this many milliseconds:72# (this even includes keystrokes when using the terminal)73MESG_QUEUE_INTERVAL_MS = 074# If a client sends a massive burst of messages, we discard all but the most recent this many of them:75# The client *should* be implemented in a way so that this never happens, and when that is76# the case -- according to our loging -- we might switch to immediately banning clients that77# hit these limits...78MESG_QUEUE_MAX_COUNT = 30079MESG_QUEUE_MAX_WARN = 508081# Any messages larger than this is dropped (it could take a long time to handle, by a de-JSON'ing attack, etc.).82# On the other hand, it is good to make this large enough that projects can save83MESG_QUEUE_MAX_SIZE_MB = 208485# How long to cache a positive authentication for using a project.86CACHE_PROJECT_AUTH_MS = 1000*60*15 # 15 minutes8788# How long all info about a websocket Client connection89# is kept in memory after a user disconnects. This makes it90# so that if they quickly reconnect, the connections to projects91# and other state doesn't have to be recomputed.92CLIENT_DESTROY_TIMER_S = 60*10 # 10 minutes93#CLIENT_DESTROY_TIMER_S = 0.1 # instant -- for debugging9495CLIENT_MIN_ACTIVE_S = 459697# How frequently we tell the browser clients to report metrics back to us.98# Set to 0 to completely disable metrics collection from clients.99CLIENT_METRICS_INTERVAL_S = if DEBUG2 then 15 else 60*2100101# recording metrics and statistics102metrics_recorder = require('./metrics-recorder')103104# setting up client metrics105mesg_from_client_total = metrics_recorder.new_counter('mesg_from_client_total',106'counts Client::handle_json_message_from_client invocations', ['event'])107push_to_client_stats_h = metrics_recorder.new_histogram('push_to_client_histo_ms', 'Client: push_to_client',108buckets : [1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000]109labels: ['event']110)111112# All known metrics from connected clients. (Map from id to metrics.)113# id is deleted from this when client disconnects.114client_metrics = metrics_recorder.client_metrics115116if not misc.is_object(client_metrics)117throw Error("metrics_recorder must have a client_metrics attribute map")118119class exports.Client extends EventEmitter120constructor: (opts) ->121super()122@_opts = defaults opts,123conn : undefined124logger : undefined125database : required126projectControl : required127host : undefined128port : undefined129personal : undefined130131@conn = @_opts.conn132@logger = @_opts.logger133@database = @_opts.database134@projectControl = @_opts.projectControl135136@_when_connected = new Date()137138@_messages =139being_handled : {}140total_time : 0141count : 0142143# The variable account_id is either undefined or set to the144# account id of the user that this session has successfully145# authenticated as. Use @account_id to decide whether or not146# it is safe to carry out a given action.147@account_id = undefined148149if @conn?150# has a persistent connection, e.g., NOT just used for an API151@init_conn()152else153@id = misc.uuid()154155@copy_path = new CopyPath(@)156157init_conn: =>158# initialize everything related to persistent connections159@_data_handlers = {}160@_data_handlers[JSON_CHANNEL] = @handle_json_message_from_client161162# The persistent sessions that this client starts.163@compute_session_uuids = []164165@install_conn_handlers()166167@ip_address = @conn.address.ip168169# A unique id -- can come in handy170@id = @conn.id171172# Setup remember-me related cookie handling173@cookies = {}174c = new Cookies(@conn.request)175@_remember_me_value = c.get(REMEMBER_ME_COOKIE_NAME)176177@check_for_remember_me()178179# Security measure: check every 5 minutes that remember_me180# cookie used for login is still valid. If the cookie is gone181# and this fails, user gets a message, and see that they must sign in.182@_remember_me_interval = setInterval(@check_for_remember_me, 1000*60*5)183184if CLIENT_METRICS_INTERVAL_S185@push_to_client(message.start_metrics(interval_s:CLIENT_METRICS_INTERVAL_S))186187touch: (opts={}) =>188if not @account_id # not logged in189opts.cb?('not logged in')190return191opts = defaults opts,192project_id : undefined193path : undefined194action : 'edit'195force : false196cb : undefined197# touch -- indicate by changing field in database that this user is active.198# We do this at most once every CLIENT_MIN_ACTIVE_S seconds, for given choice199# of project_id, path (unless force is true).200if not @_touch_lock?201@_touch_lock = {}202key = "#{opts.project_id}-#{opts.path}-#{opts.action}"203if not opts.force and @_touch_lock[key]204opts.cb?("touch lock")205return206opts.account_id = @account_id207@_touch_lock[key] = true208delete opts.force209@database.touch(opts)210211setTimeout((()=>delete @_touch_lock[key]), CLIENT_MIN_ACTIVE_S*1000)212213install_conn_handlers: () =>214dbg = @dbg('install_conn_handlers')215if @_destroy_timer?216clearTimeout(@_destroy_timer)217delete @_destroy_timer218219@conn.on "data", (data) =>220@handle_data_from_client(data)221222@conn.on "end", () =>223dbg("connection: hub <--> client(id=#{@id}, address=#{@ip_address}) -- CLOSED")224@destroy()225###226# I don't think this destroy_timer is of any real value at all unless227# we were to fully maintain client state while they are gone. Doing this228# is a serious liability, e.g., in a load-spike situation.229# CRITICAL -- of course we need to cancel all changefeeds when user disconnects,230# even temporarily, since messages could be dropped otherwise. (The alternative is to231# cache all messages in the hub, which has serious memory implications.)232@query_cancel_all_changefeeds()233# Actually destroy Client in a few minutes, unless user reconnects234# to this session. Often the user may have a temporary network drop,235# and we keep everything waiting for them for short time236# in case this happens.237@_destroy_timer = setTimeout(@destroy, 1000*CLIENT_DESTROY_TIMER_S)238###239240dbg("connection: hub <--> client(id=#{@id}, address=#{@ip_address}) ESTABLISHED")241242dbg: (desc) =>243if @logger?.debug244return (args...) => @logger.debug("Client(#{@id}).#{desc}:", args...)245else246return ->247248destroy: () =>249dbg = @dbg('destroy')250dbg("destroy connection: hub <--> client(id=#{@id}, address=#{@ip_address}) -- CLOSED")251252if @id253# cancel any outstanding queries.254@database.cancel_user_queries(client_id:@id)255256delete @_project_cache257delete client_metrics[@id]258clearInterval(@_remember_me_interval)259@query_cancel_all_changefeeds()260@closed = true261@emit('close')262@compute_session_uuids = []263c = clients[@id]264delete clients[@id]265dbg("num_clients=#{misc.len(clients)}")266if c? and c.call_callbacks?267for id,f of c.call_callbacks268f("connection closed")269delete c.call_callbacks270for h in local_hub_connection.all_local_hubs()271h.free_resources_for_client_id(@id)272273remember_me_failed: (reason) =>274return if not @conn?275@signed_out() # so can't do anything with projects, etc.276@push_to_client(message.remember_me_failed(reason:reason))277278get_personal_user: () =>279if @account_id or not @conn? or not @_opts.personal280# there is only one account281return282dbg = @dbg("check_for_remember_me")283dbg("personal mode")284try285signed_in_mesg = {account_id:await get_personal_user(@database), event:'signed_in'}286# sign them in if not already signed in (due to async this could happen287# by get_personal user getting called twice at once).288if @account_id != signed_in_mesg.account_id289signed_in_mesg.hub = @_opts.host + ':' + @_opts.port290@signed_in(signed_in_mesg)291@push_to_client(signed_in_mesg)292catch err293dbg("remember_me: personal mode error", err.toString())294@remember_me_failed("error getting personal user -- #{err}")295return296297check_for_remember_me: () =>298return if not @conn?299dbg = @dbg("check_for_remember_me")300301if @_opts.personal302@get_personal_user()303return304305value = @_remember_me_value306if not value?307@remember_me_failed("no remember_me cookie")308return309x = value.split('$')310if x.length != 4311@remember_me_failed("invalid remember_me cookie")312return313try314hash = generateHash(x[0], x[1], x[2], x[3])315catch err316dbg("unable to generate hash from '#{value}' -- #{err}")317@remember_me_failed("invalid remember_me cookie")318return319320dbg("checking for remember_me cookie with hash='#{hash.slice(0,15)}...'") # don't put all in log -- could be dangerous321@database.get_remember_me322hash : hash323cb : (error, signed_in_mesg) =>324dbg("remember_me: got ", error)325if error326@remember_me_failed("error accessing database")327return328if not signed_in_mesg or not signed_in_mesg.account_id329@remember_me_failed("remember_me deleted or expired")330return331# sign them in if not already signed in332if @account_id != signed_in_mesg.account_id333# DB only tells us the account_id, but the hub might have changed from last time334signed_in_mesg.hub = @_opts.host + ':' + @_opts.port335@hash_session_id = hash336@signed_in(signed_in_mesg)337@push_to_client(signed_in_mesg)338339push_to_client: (mesg, cb) =>340###341Pushing messages to this particular connected client342###343if @closed344cb?("disconnected")345return346dbg = @dbg("push_to_client")347348if mesg.event != 'pong'349dbg("hub --> client (client=#{@id}): #{misc.trunc(to_safe_str(mesg),300)}")350#dbg("hub --> client (client=#{@id}): #{misc.trunc(JSON.stringify(mesg),1000)}")351#dbg("hub --> client (client=#{@id}): #{JSON.stringify(mesg)}")352353if mesg.id?354start = @_messages.being_handled[mesg.id]355if start?356time_taken = new Date() - start357delete @_messages.being_handled[mesg.id]358@_messages.total_time += time_taken359@_messages.count += 1360avg = Math.round(@_messages.total_time / @_messages.count)361dbg("[#{time_taken} mesg_time_ms] [#{avg} mesg_avg_ms] -- mesg.id=#{mesg.id}")362push_to_client_stats_h.observe({event:mesg.event}, time_taken)363364# If cb *is* given and mesg.id is *not* defined, then365# we also setup a listener for a response from the client.366listen = cb? and not mesg.id?367if listen368# This message is not a response to a client request.369# Instead, we are initiating a request to the user and we370# want a result back (hence cb? being defined).371mesg.id = misc.uuid()372if not @call_callbacks?373@call_callbacks = {}374@call_callbacks[mesg.id] = cb375f = () =>376g = @call_callbacks?[mesg.id]377if g?378delete @call_callbacks[mesg.id]379g("timed out")380setTimeout(f, 15000) # timeout after some seconds381382t = new Date()383data = misc.to_json_socket(mesg)384tm = new Date() - t385if tm > 10386dbg("mesg.id=#{mesg.id}: time to json=#{tm}ms; length=#{data.length}; value='#{misc.trunc(data, 500)}'")387@push_data_to_client(data)388if not listen389cb?()390return391392push_data_to_client: (data) ->393return if not @conn?394if @closed395return396@conn.write(data)397398error_to_client: (opts) =>399opts = defaults opts,400id : undefined401error : required402if opts.error instanceof Error403# Javascript Errors as come up with exceptions don't JSON.404# Since the point is just to show an error to the client,405# it is better to send back the string!406opts.error = opts.error.toString()407@push_to_client(message.error(id:opts.id, error:opts.error))408409success_to_client: (opts) =>410opts = defaults opts,411id : required412@push_to_client(message.success(id:opts.id))413414signed_in: (signed_in_mesg) =>415return if not @conn?416# Call this method when the user has successfully signed in.417418@signed_in_mesg = signed_in_mesg # save it, since the properties are handy to have.419420# Record that this connection is authenticated as user with given uuid.421@account_id = signed_in_mesg.account_id422423sign_in.record_sign_in424ip_address : @ip_address425successful : true426account_id : signed_in_mesg.account_id427database : @database428429# Get user's group from database.430@get_groups()431432signed_out: () =>433@account_id = undefined434435# Setting and getting HTTP-only cookies via Primus + AJAX436get_cookie: (opts) =>437opts = defaults opts,438name : required439cb : required # cb(undefined, value)440if not @conn?.id?441# no connection or connection died442return443@once("get_cookie-#{opts.name}", (value) -> opts.cb(value))444@push_to_client(message.cookies(id:@conn.id, get:opts.name, url:path_join(base_path, "cookies")))445446447invalidate_remember_me: (opts) =>448return if not @conn?449450opts = defaults opts,451cb : required452453if @hash_session_id?454@database.delete_remember_me455hash : @hash_session_id456cb : opts.cb457else458opts.cb()459460###461Our realtime socket connection might only support one connection462between the client and463server, so we multiplex multiple channels over the same464connection. There is one base channel for JSON messages called465JSON_CHANNEL, which themselves can be routed to different466callbacks, etc., by the client code. There are 16^4-1 other467channels, which are for sending raw data. The raw data messages468are prepended with a UTF-16 character that identifies the469channel. The channel character is random (which might be more470secure), and there is no relation between the channels for two471distinct clients.472###473474handle_data_from_client: (data) =>475return if not @conn?476dbg = @dbg("handle_data_from_client")477## Only enable this when doing low level debugging -- performance impacts AND leakage of dangerous info!478if DEBUG2479dbg("handle_data_from_client('#{misc.trunc(data.toString(),400)}')")480481# TODO: THIS IS A SIMPLE anti-DOS measure; it might be too482# extreme... we shall see. It prevents a number of attacks,483# e.g., users storing a multi-gigabyte worksheet title,484# etc..., which would (and will) otherwise require care with485# every single thing we store.486487# TODO: the two size things below should be specific messages (not generic error_to_client), and488# be sensibly handled by the client.489if data.length >= MESG_QUEUE_MAX_SIZE_MB * 10000000490# 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 only491# happen because of a malicious attacker. JSON parsing arbitrarily large strings would492# be very dangerous, and make crashing the server way too easy.493# We just respond with this error below. The client should display to the user all id-less errors.494msg = "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."495@logger?.error(msg)496@error_to_client(error:msg)497return498499if not @_handle_data_queue?500@_handle_data_queue = []501502# The rest of the function is basically the same as "h(data.slice(1))", except that503# it ensure that if there is a burst of messages, then (1) we handle at most 1 message504# per client every MESG_QUEUE_INTERVAL_MS, and we drop messages if there are too many.505# This is an anti-DOS measure.506@_handle_data_queue.push([@handle_json_message_from_client, data])507508if @_handle_data_queue_empty_function?509return510511# define a function to empty the queue512@_handle_data_queue_empty_function = () =>513if @_handle_data_queue.length == 0514# done doing all tasks515delete @_handle_data_queue_empty_function516return517518if @_handle_data_queue.length > MESG_QUEUE_MAX_WARN519dbg("MESG_QUEUE_MAX_WARN(=#{MESG_QUEUE_MAX_WARN}) exceeded (=#{@_handle_data_queue.length}) -- just a warning")520521# drop oldest message to keep522if @_handle_data_queue.length > MESG_QUEUE_MAX_COUNT523dbg("MESG_QUEUE_MAX_COUNT(=#{MESG_QUEUE_MAX_COUNT}) exceeded (=#{@_handle_data_queue.length}) -- drop oldest messages")524while @_handle_data_queue.length > MESG_QUEUE_MAX_COUNT525discarded_mesg = @_handle_data_queue.shift()526data = discarded_mesg?[1]527dbg("discarded_mesg='#{misc.trunc(data?.toString?(),1000)}'")528529530# get task531task = @_handle_data_queue.shift()532# do task533task[0](task[1])534# do next one in >= MESG_QUEUE_INTERVAL_MS535setTimeout( @_handle_data_queue_empty_function, MESG_QUEUE_INTERVAL_MS )536537@_handle_data_queue_empty_function()538539register_data_handler: (h) ->540return if not @conn?541# generate a channel character that isn't already taken -- if these get too large,542# this will break (see, e.g., http://blog.fgribreau.com/2012/05/how-to-fix-could-not-decode-text-frame.html);543# however, this is a counter for *each* individual user connection, so they won't get too big.544# Ultimately, we'll redo things to use primus/websocket channel support, which should be much more powerful545# and faster.546if not @_last_channel?547@_last_channel = 1548while true549@_last_channel += 1550channel = String.fromCharCode(@_last_channel)551if not @_data_handlers[channel]?552break553@_data_handlers[channel] = h554return channel555556###557Message handling functions:558559Each function below that starts with mesg_ handles a given560message type (an event). The implementations of many of the561handlers are somewhat long/involved, so the function below562immediately calls another function defined elsewhere. This will563make it easier to refactor code to other modules, etc., later.564This approach also clarifies what exactly about this object565is used to implement the relevant functionality.566###567handle_json_message_from_client: (data) =>568return if not @conn?569if @_ignore_client570return571try572mesg = misc.from_json_socket(data)573catch error574@logger?.error("error parsing incoming mesg (invalid JSON): #{mesg}")575return576dbg = @dbg('handle_json_message_from_client')577if mesg.event != 'ping'578dbg("hub <-- client: #{misc.trunc(to_safe_str(mesg), 120)}")579580# check for message that is coming back in response to a request from the hub581if @call_callbacks? and mesg.id?582f = @call_callbacks[mesg.id]583if f?584delete @call_callbacks[mesg.id]585f(undefined, mesg)586return587588if mesg.id?589@_messages.being_handled[mesg.id] = new Date()590591handler = @["mesg_#{mesg.event}"]592if handler?593try594await handler(mesg)595catch err596# handler *should* handle any possible error, but just in case something597# not expected goes wrong... we do this598@error_to_client(id:mesg.id, error:"${err}")599mesg_from_client_total.labels("#{mesg.event}").inc(1)600else601@push_to_client(message.error(error:"Hub does not know how to handle a '#{mesg.event}' event.", id:mesg.id))602if mesg.event == 'get_all_activity'603dbg("ignoring all further messages from old client=#{@id}")604@_ignore_client = true605606mesg_ping: (mesg) =>607@push_to_client(message.pong(id:mesg.id, now:new Date()))608609mesg_sign_in: (mesg) =>610sign_in.sign_in611client : @612mesg : mesg613logger : @logger614database : @database615host : @_opts.host616port : @_opts.port617618mesg_sign_out: (mesg) =>619if not @account_id?620@push_to_client(message.error(id:mesg.id, error:"not signed in"))621return622623if mesg.everywhere624# invalidate all remember_me cookies625@database.invalidate_all_remember_me626account_id : @account_id627@signed_out() # deletes @account_id... so must be below database call above628# invalidate the remember_me on this browser629@invalidate_remember_me630cb:(error) =>631@dbg('mesg_sign_out')("signing out: #{mesg.id}, #{error}")632if error633@push_to_client(message.error(id:mesg.id, error:error))634else635@push_to_client(message.signed_out(id:mesg.id))636637# Messages: Password/email address management638mesg_change_email_address: (mesg) =>639try640await setEmailAddress641account_id: @account_id642email_address: mesg.new_email_address643password: mesg.password644@push_to_client(message.changed_email_address(id:mesg.id))645catch err646@error_to_client(id:mesg.id, error:err)647648mesg_send_verification_email: (mesg) =>649auth = require('./auth')650auth.verify_email_send_token651account_id : mesg.account_id652only_verify : mesg.only_verify ? true653database : @database654cb : (err) =>655if err656@error_to_client(id:mesg.id, error:err)657else658@success_to_client(id:mesg.id)659660mesg_unlink_passport: (mesg) =>661if not @account_id?662@error_to_client(id:mesg.id, error:"must be logged in")663else664opts =665account_id : @account_id666strategy : mesg.strategy667id : mesg.id668cb : (err) =>669if err670@error_to_client(id:mesg.id, error:err)671else672@success_to_client(id:mesg.id)673delete_passport(@database, opts)674675# Messages: Account settings676get_groups: (cb) =>677# see note above about our "infinite caching". Maybe a bad idea.678if @groups?679cb?(undefined, @groups)680return681@database.get_account682columns : ['groups']683account_id : @account_id684cb : (err, r) =>685if err686cb?(err)687else688@groups = r['groups']689cb?(undefined, @groups)690691# Messages: Log errors that client sees so we can also look at them692mesg_log_client_error: (mesg) =>693@dbg('mesg_log_client_error')(mesg.error)694if not mesg.type?695mesg.type = "error"696if not mesg.error?697mesg.error = "error"698@database.log_client_error699event : mesg.type700error : mesg.error701account_id : @account_id702cb : (err) =>703if not mesg.id?704return705if err706@error_to_client(id:mesg.id, error:err)707else708@success_to_client(id:mesg.id)709710mesg_webapp_error: (mesg) =>711# Tell client we got it, thanks:712@success_to_client(id:mesg.id)713# Now do something with it.714@dbg('mesg_webapp_error')(mesg.msg)715mesg = misc.copy_without(mesg, 'event')716mesg.account_id = @account_id717@database.webapp_error(mesg)718719# Messages: Project Management720get_project: (mesg, permission, cb) =>721###722How to use this: Either call the callback with the project, or if an error err723occured, call @error_to_client(id:mesg.id, error:err) and *NEVER*724call the callback. This function is meant to be used in a bunch725of the functions below for handling requests.726727mesg -- must have project_id field728permission -- must be "read" or "write"729cb(err, project)730*NOTE*: on failure, if mesg.id is defined, then client will receive731an error message; the function calling get_project does *NOT*732have to send the error message back to the client!733###734dbg = @dbg('get_project')735736err = undefined737if not mesg.project_id?738err = "mesg must have project_id attribute -- #{to_safe_str(mesg)}"739else if not @account_id?740err = "user must be signed in before accessing projects"741742if err743if mesg.id?744@error_to_client(id:mesg.id, error:err)745cb(err)746return747748key = mesg.project_id + permission749project = @_project_cache?[key]750if project?751# Use the cached project so we don't have to re-verify authentication752# for the user again below, which753# is very expensive. This cache does expire, in case user754# is kicked out of the project.755cb(undefined, project)756return757758dbg()759async.series([760(cb) =>761switch permission762when 'read'763access.user_has_read_access_to_project764project_id : mesg.project_id765account_id : @account_id766account_groups : @groups767database : @database768cb : (err, result) =>769if err770cb("Read access denied -- #{err}")771else if not result772cb("User #{@account_id} does not have read access to project #{mesg.project_id}")773else774# good to go775cb()776when 'write'777access.user_has_write_access_to_project778database : @database779project_id : mesg.project_id780account_groups : @groups781account_id : @account_id782cb : (err, result) =>783if err784cb("Write access denied -- #{err}")785else if not result786cb("User #{@account_id} does not have write access to project #{mesg.project_id}")787else788# good to go789cb()790else791cb("Internal error -- unknown permission type '#{permission}'")792], (err) =>793if err794if mesg.id?795@error_to_client(id:mesg.id, error:err)796dbg("error -- #{err}")797cb(err)798else799project = hub_projects.new_project(mesg.project_id, @database, @projectControl)800@database.touch_project(project_id:mesg.project_id)801@_project_cache ?= {}802@_project_cache[key] = project803# cache for a while804setTimeout((()=>delete @_project_cache?[key]), CACHE_PROJECT_AUTH_MS)805dbg("got project; caching and returning")806cb(undefined, project)807)808809mesg_create_project: (mesg) =>810if not @account_id?811@error_to_client(id: mesg.id, error: "You must be signed in to create a new project.")812return813@touch()814815dbg = @dbg('mesg_create_project')816817project_id = undefined818project = undefined819820async.series([821(cb) =>822dbg("create project entry in database")823try824opts =825account_id : @account_id826title : mesg.title827description : mesg.description828image : mesg.image829license : mesg.license830noPool : mesg.noPool831project_id = await create_project(opts)832cb(undefined)833catch err834cb(err)835(cb) =>836cb() # we don't need to wait for project to start running before responding to user that project was created.837dbg("open project...")838# We do the open/start below so that when user tries to open it in a moment it opens more quickly;839# also, in single dev mode, this ensures that project path is created, so can copy840# files to the project, etc.841# Also, if mesg.start is set, the project gets started below.842try843project = await @projectControl(project_id)844await project.state(force:true, update:true)845if mesg.start846await project.start()847await delay(5000) # just in case848await project.start()849else850dbg("not auto-starting the new project")851catch err852dbg("failed to start project running -- #{err}")853], (err) =>854if err855dbg("error; project #{project_id} -- #{err}")856@error_to_client(id: mesg.id, error: "Failed to create new project '#{mesg.title}' -- #{misc.to_json(err)}")857else858dbg("SUCCESS: project #{project_id}")859@push_to_client(message.project_created(id:mesg.id, project_id:project_id))860# As an optimization, we start the process of opening the project, since the user is likely861# to open the project soon anyways.862dbg("start process of opening project")863@get_project {project_id:project_id}, 'write', (err, project) =>864)865866mesg_write_text_file_to_project: (mesg) =>867@get_project mesg, 'write', (err, project) =>868if err869return870project.write_file871path : mesg.path872data : mesg.content873cb : (err) =>874if err875@error_to_client(id:mesg.id, error:err)876else877@push_to_client(message.file_written_to_project(id:mesg.id))878879mesg_read_text_files_from_projects: (mesg) =>880if not misc.is_array(mesg.project_id)881@error_to_client(id:mesg.id, error:"project_id must be an array")882return883if not misc.is_array(mesg.path) or mesg.path.length != mesg.project_id.length884@error_to_client(id:mesg.id, error:"if project_id is an array, then path must be an array of the same length")885return886v = []887f = (mesg, cb) =>888@get_project mesg, 'read', (err, project) =>889if err890cb(err)891return892project.read_file893path : mesg.path894cb : (err, content) =>895if err896v.push({path:mesg.path, project_id:mesg.project_id, error:err})897else898v.push({path:mesg.path, project_id:mesg.project_id, content:content.blob.toString()})899cb()900paths = []901for i in [0...mesg.project_id.length]902paths.push({id:mesg.id, path:mesg.path[i], project_id:mesg.project_id[i]})903async.mapLimit paths, 20, f, (err) =>904if err905@error_to_client(id:mesg.id, error:err)906else907@push_to_client(message.text_file_read_from_project(id:mesg.id, content:v))908909mesg_read_text_file_from_project: (mesg) =>910if misc.is_array(mesg.project_id)911@mesg_read_text_files_from_projects(mesg)912return913@get_project mesg, 'read', (err, project) =>914if err915return916project.read_file917path : mesg.path918cb : (err, content) =>919if err920@error_to_client(id:mesg.id, error:err)921else922t = content.blob.toString()923@push_to_client(message.text_file_read_from_project(id:mesg.id, content:t))924925mesg_project_exec: (mesg) =>926if mesg.command == "ipython-notebook"927# we just drop these messages, which are from old non-updated clients (since we haven't928# written code yet to not allow them to connect -- TODO!).929return930@get_project mesg, 'write', (err, project) =>931if err932return933project.call934mesg : mesg935timeout : mesg.timeout936cb : (err, resp) =>937if err938@error_to_client(id:mesg.id, error:err)939else940@push_to_client(resp)941942mesg_copy_path_between_projects: (mesg) =>943@copy_path.copy(mesg)944945mesg_copy_path_status: (mesg) =>946@copy_path.status(mesg)947948mesg_copy_path_delete: (mesg) =>949@copy_path.delete(mesg)950951mesg_local_hub: (mesg) =>952###953Directly communicate with the local hub. If the954client has write access to the local hub, there's no955reason they shouldn't be allowed to send arbitrary956messages directly (they could anyways from the terminal).957###958dbg = @dbg('mesg_local_hub')959dbg("hub --> local_hub: ", mesg)960@get_project mesg, 'write', (err, project) =>961if err962return963if not mesg.message?964# in case the message itself is invalid -- is possible965@error_to_client(id:mesg.id, error:"message must be defined")966return967968if mesg.message.event == 'project_exec' and mesg.message.command == "ipython-notebook"969# we just drop these messages, which are from old non-updated clients (since we haven't970# written code yet to not allow them to connect -- TODO!).971return972973# It's extremely useful if the local hub has a way to distinguish between different clients who are974# being proxied through the same hub.975mesg.message.client_id = @id976977# Make the actual call978project.call979mesg : mesg.message980timeout : mesg.timeout981multi_response : mesg.multi_response982cb : (err, resp) =>983if err984dbg("ERROR: #{err} calling message #{misc.to_json(mesg.message)}")985@error_to_client(id:mesg.id, error:err)986else987if not mesg.multi_response988resp.id = mesg.id989@push_to_client(resp)990991mesg_user_search: (mesg) =>992if not @account_id?993@push_to_client(message.error(id:mesg.id, error:"You must be signed in to search for users."))994return995996if not mesg.admin and (not mesg.limit? or mesg.limit > 50)997# hard cap at 50... (for non-admin)998mesg.limit = 50999locals = {results: undefined}1000async.series([1001(cb) =>1002if mesg.admin1003@assert_user_is_in_group('admin', cb)1004else1005cb()1006(cb) =>1007@touch()1008opts =1009query : mesg.query1010limit : mesg.limit1011admin : mesg.admin1012active : mesg.active1013only_email: mesg.only_email1014try1015locals.results = await user_search(opts)1016cb(undefined)1017catch err1018cb(err)1019], (err) =>1020if err1021@error_to_client(id:mesg.id, error:err)1022else1023@push_to_client(message.user_search_results(id:mesg.id, results:locals.results))1024)102510261027# this is an async function1028allow_urls_in_emails: (project_id) =>1029is_paying = await is_paying_customer(@database, @account_id)1030has_network = await project_has_network_access(@database, project_id)1031return is_paying or has_network10321033mesg_invite_collaborator: (mesg) =>1034@touch()1035dbg = @dbg('mesg_invite_collaborator')1036#dbg("mesg: #{misc.to_json(mesg)}")1037@get_project mesg, 'write', (err, project) =>1038if err1039return1040locals =1041email_address : undefined1042done : false1043settings : undefined10441045# SECURITY NOTE: mesg.project_id is valid and the client has write access, since otherwise,1046# the @get_project function above wouldn't have returned without err...1047async.series([1048(cb) =>1049@database.add_user_to_project1050project_id : mesg.project_id1051account_id : mesg.account_id1052group : 'collaborator' # in future will be "invite_collaborator", once implemented1053cb : cb10541055(cb) =>1056# only send an email when there is an mesg.email body to send.1057# we want to make it explicit when they're sent, and implicitly disable it for API usage.1058if not mesg.email?1059locals.done = true1060cb()10611062(cb) =>1063if locals.done1064cb(); return10651066@database._query1067query : "SELECT email_address FROM accounts"1068where : "account_id = $::UUID" : mesg.account_id1069cb : one_result 'email_address', (err, x) =>1070locals.email_address = x1071cb(err)10721073(cb) =>1074if (not locals.email_address) or locals.done1075cb(); return10761077# INFO: for testing this, you have to reset the invite field each time you sent yourself an invitation1078# in psql: UPDATE projects SET invite = NULL WHERE project_id = '<UUID of your cc-in-cc dev project>';1079@database.when_sent_project_invite1080project_id : mesg.project_id1081to : locals.email_address1082cb : (err, when_sent) =>1083#console.log("mesg_invite_collaborator email #{locals.email_address}, #{err}, #{when_sent}")1084if err1085cb(err)1086else if when_sent >= misc.days_ago(7) # successfully sent < one week ago -- don't again1087locals.done = true1088cb()1089else1090cb()10911092(cb) =>1093if locals.done or (not locals.email_address)1094cb()1095return1096@database.get_server_settings_cached1097cb : (err, settings) =>1098if err1099cb(err)1100else if not settings?1101cb("no server settings -- no database connection?")1102else1103locals.settings = settings1104cb()11051106(cb) =>1107if locals.done or (not locals.email_address)1108dbg("NOT send_email invite to #{locals.email_address}")1109cb()1110return11111112## do not send email if project doesn't have network access (and user is not a paying customer)1113#if (not await is_paying_customer(@database, @account_id) and not await project_has_network_access(@database, mesg.project_id))1114# dbg("NOT send_email invite to #{locals.email_address} -- due to project lacking network access (and user not a customer)")1115# return11161117# we always send invite emails. for non-upgraded projects, we sanitize the content of the body1118# ATTN: this must harmonize with @cocalc/frontend/projects → allow_urls_in_emails1119# also see mesg_invite_noncloud_collaborators11201121dbg("send_email invite to #{locals.email_address}")1122# available message fields1123# mesg.title - title of project1124# mesg.link2proj1125# mesg.replyto1126# mesg.replyto_name1127# mesg.email - body of email1128# mesg.subject11291130# send an email to the user -- async, not blocking user.1131# TODO: this can take a while -- we need to take some action1132# if it fails, e.g., change a setting in the projects table!1133if mesg.replyto_name?1134subject = "#{mesg.replyto_name} invited you to collaborate on CoCalc in project '#{mesg.title}'"1135else1136subject = "Invitation to CoCalc for collaborating in project '#{mesg.title}'"1137# override subject if explicitly given1138if mesg.subject?1139subject = mesg.subject11401141send_invite_email1142to : locals.email_address1143subject : subject1144email : mesg.email1145email_address : locals.email_address1146title : mesg.title1147allow_urls : await @allow_urls_in_emails(mesg.project_id)1148replyto : mesg.replyto ? locals.settings.organization_email1149replyto_name : mesg.replyto_name1150link2proj : mesg.link2proj1151settings : locals.settings1152cb : (err) =>1153if err1154dbg("FAILED to send email to #{locals.email_address} -- err=#{misc.to_json(err)}")1155@database.sent_project_invite1156project_id : mesg.project_id1157to : locals.email_address1158error : err1159cb(err) # call the cb one scope up so that the client is informed that we sent the invite (or not)11601161], (err) =>1162if err1163@error_to_client(id:mesg.id, error:err)1164else1165@push_to_client(message.success(id:mesg.id))1166)11671168mesg_add_license_to_project: (mesg) =>1169dbg = @dbg('mesg_add_license_to_project')1170dbg()1171@touch()1172@_check_project_access mesg.project_id, (err) =>1173if err1174dbg("failed -- #{err}")1175@error_to_client(id:mesg.id, error:"must have write access to #{mesg.project_id} -- #{err}")1176return1177try1178await addLicenseToProject({project_id:mesg.project_id, license_id:mesg.license_id})1179@success_to_client(id:mesg.id)1180catch err1181@error_to_client(id:mesg.id, error:"#{err}")11821183mesg_remove_license_from_project: (mesg) =>1184dbg = @dbg('mesg_remove_license_from_project')1185dbg()1186@touch()1187@_check_project_access mesg.project_id, (err) =>1188if err1189dbg("failed -- #{err}")1190@error_to_client(id:mesg.id, error:"must have write access to #{mesg.project_id} -- #{err}")1191return1192try1193await removeLicenseFromProject({project_id:mesg.project_id, license_id:mesg.license_id})1194@success_to_client(id:mesg.id)1195catch err1196@error_to_client(id:mesg.id, error:"#{err}")11971198mesg_invite_noncloud_collaborators: (mesg) =>1199dbg = @dbg('mesg_invite_noncloud_collaborators')12001201#1202# Uncomment this in case of attack by evil forces:1203#1204## @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");1205## return12061207# Otherwise we always allow sending email invites1208# The body is sanitized and not allowed to contain any URLs (anti-spam), unless1209# (a) the sender is a paying customer or (b) the project has network access.1210#1211# ATTN: this must harmonize with @cocalc/frontend/projects → allow_urls_in_emails1212# also see mesg_invite_collaborator12131214@touch()1215@get_project mesg, 'write', (err, project) =>1216if err1217return12181219if mesg.to.length > 10241220@error_to_client(id:mesg.id, error:"Specify less recipients when adding collaborators to project.")1221return12221223# users to invite1224to = (x for x in mesg.to.replace(/\s/g,",").replace(/;/g,",").split(',') when x)12251226invite_user = (email_address, cb) =>1227dbg("inviting #{email_address}")1228if not misc.is_valid_email_address(email_address)1229cb("invalid email address '#{email_address}'")1230return1231email_address = misc.lower_email_address(email_address)1232if email_address.length >= 1281233# if an attacker tries to embed a spam in the email address itself (e.g, [email protected]), then1234# at least we can limit its size.1235cb("email address must be at most 128 characters: '#{email_address}'")1236return12371238locals =1239done : false1240account_id : undefined1241settings : undefined12421243async.series([1244# already have an account?1245(cb) =>1246@database.account_exists1247email_address : email_address1248cb : (err, _account_id) =>1249dbg("account_exists: #{err}, #{_account_id}")1250locals.account_id = _account_id1251cb(err)1252(cb) =>1253if locals.account_id1254dbg("user #{email_address} already has an account -- add directly")1255# user has an account already1256locals.done = true1257@database.add_user_to_project1258project_id : mesg.project_id1259account_id : locals.account_id1260group : 'collaborator'1261cb : cb1262else1263dbg("user #{email_address} doesn't have an account yet -- may send email (if we haven't recently)")1264# create trigger so that when user eventually makes an account,1265# they will be added to the project.1266@database.account_creation_actions1267email_address : email_address1268action : {action:'add_to_project', group:'collaborator', project_id:mesg.project_id}1269ttl : 60*60*24*14 # valid for 14 days1270cb : cb1271(cb) =>1272if locals.done1273cb()1274else1275@database.when_sent_project_invite1276project_id : mesg.project_id1277to : email_address1278cb : (err, when_sent) =>1279if err1280cb(err)1281else if when_sent >= misc.days_ago(RESEND_INVITE_INTERVAL_DAYS) # successfully sent this long ago -- don't again1282locals.done = true1283cb()1284else1285cb()12861287(cb) =>1288if locals.done1289cb()1290return1291@database.get_server_settings_cached1292cb: (err, settings) =>1293if err1294cb(err)1295else if not settings?1296cb("no server settings -- no database connection?")1297else1298locals.settings = settings1299cb()13001301(cb) =>1302if locals.done1303dbg("NOT send_email invite to #{email_address}")1304cb()1305return13061307# send an email to the user -- async, not blocking user.1308# TODO: this can take a while -- we need to take some action1309# if it fails, e.g., change a setting in the projects table!1310subject = "CoCalc Invitation"1311# override subject if explicitly given1312if mesg.subject?1313subject = mesg.subject13141315dbg("send_email invite to #{email_address}")1316send_invite_email1317to : email_address1318subject : subject1319email : mesg.email1320email_address : email_address1321title : mesg.title1322allow_urls : await @allow_urls_in_emails(mesg.project_id)1323replyto : mesg.replyto ? locals.settings.organization_email1324replyto_name : mesg.replyto_name1325link2proj : mesg.link2proj1326settings : locals.settings1327cb : (err) =>1328if err1329dbg("FAILED to send email to #{email_address} -- err=#{misc.to_json(err)}")1330@database.sent_project_invite1331project_id : mesg.project_id1332to : email_address1333error : err1334cb(err) # call the cb one scope up so that the client is informed that we sent the invite (or not)13351336], cb)13371338async.map to, invite_user, (err, results) =>1339if err1340@error_to_client(id:mesg.id, error:err)1341else1342@push_to_client(message.invite_noncloud_collaborators_resp(id:mesg.id, mesg:"Invited #{mesg.to} to collaborate on a project."))13431344mesg_remove_collaborator: (mesg) =>1345@touch()1346@get_project mesg, 'write', (err, project) =>1347if err1348return1349# See "Security note" in mesg_invite_collaborator1350@database.remove_collaborator_from_project1351project_id : mesg.project_id1352account_id : mesg.account_id1353cb : (err) =>1354if err1355@error_to_client(id:mesg.id, error:err)1356else1357@push_to_client(message.success(id:mesg.id))13581359# NOTE: this is different than invite_collab, in that it is1360# much more similar to remove_collaborator. It also supports1361# adding multiple collabs to multiple projects (NOT in one1362# transaction, though).1363mesg_add_collaborator: (mesg) =>1364@touch()1365projects = mesg.project_id1366accounts = mesg.account_id1367tokens = mesg.token_id13681369is_single_token = false1370if tokens1371if not misc.is_array(tokens)1372is_single_token = true1373tokens = [tokens]1374projects = ('' for _ in [0...tokens.length]) # will get mutated below as tokens are used1375if not misc.is_array(projects)1376projects = [projects]1377if not misc.is_array(accounts)1378accounts = [accounts]13791380try1381await collab.add_collaborators_to_projects(@database, @account_id, accounts, projects, tokens)1382resp = message.success(id:mesg.id)1383if tokens1384# Tokens determine the projects, and it maybe useful to the client to know what1385# project they just got added to!1386if is_single_token1387resp.project_id = projects[0]1388else1389resp.project_id = projects1390@push_to_client(resp)1391catch err1392@error_to_client(id:mesg.id, error:"#{err}")13931394mesg_remove_blob_ttls: (mesg) =>1395if not @account_id?1396@push_to_client(message.error(id:mesg.id, error:"not yet signed in"))1397else1398@database.remove_blob_ttls1399uuids : mesg.uuids1400cb : (err) =>1401if err1402@error_to_client(id:mesg.id, error:err)1403else1404@push_to_client(message.success(id:mesg.id))14051406mesg_version: (mesg) =>1407# The version of the client...1408@smc_version = mesg.version1409@dbg('mesg_version')("client.smc_version=#{mesg.version}")1410{version} = await require('./servers/server-settings').default()1411if mesg.version < version.version_recommended_browser ? 01412@push_version_update()14131414push_version_update: =>1415{version} = await require('./servers/server-settings').default()1416@push_to_client(message.version(version:version.version_recommended_browser, min_version:version.version_min_browser))1417if version.version_min_browser and @smc_version < version.version_min_browser1418# Client is running an unsupported bad old version.1419# Brutally disconnect client! It's critical that they upgrade, since they are1420# causing problems or have major buggy code.1421if new Date() - @_when_connected <= 300001422# If they just connected, kill the connection instantly1423@conn.end()1424else1425# Wait 1 minute to give them a chance to save data...1426setTimeout((()=>@conn.end()), 60000)14271428_user_is_in_group: (group) =>1429return @groups? and group in @groups14301431assert_user_is_in_group: (group, cb) =>1432@get_groups (err) =>1433if not err and not @_user_is_in_group('admin') # user_is_in_group works after get_groups is called.1434err = "must be logged in and a member of the admin group"1435cb(err)14361437mesg_project_set_quotas: (mesg) =>1438if not misc.is_valid_uuid_string(mesg.project_id)1439@error_to_client(id:mesg.id, error:"invalid project_id")1440return1441project = undefined1442dbg = @dbg("mesg_project_set_quotas(project_id='#{mesg.project_id}')")1443async.series([1444(cb) =>1445@assert_user_is_in_group('admin', cb)1446(cb) =>1447dbg("update base quotas in the database")1448@database.set_project_settings1449project_id : mesg.project_id1450settings : misc.copy_without(mesg, ['event', 'id'])1451cb : cb1452(cb) =>1453dbg("get project from compute server")1454try1455project = await @projectControl(mesg.project_id)1456cb()1457catch err1458cb(err)1459(cb) =>1460dbg("determine total quotas and apply")1461try1462project.setAllQuotas()1463cb()1464catch err1465cb(err)1466], (err) =>1467if err1468@error_to_client(id:mesg.id, error:"problem setting project quota -- #{err}")1469else1470@push_to_client(message.success(id:mesg.id))1471)14721473###1474Public/published projects data1475###1476path_is_in_public_paths: (path, paths) =>1477return misc.path_is_in_public_paths(path, misc.keys(paths))14781479get_public_project: (opts) =>1480###1481Get a compute.Project object, or cb an error if the given1482path in the project isn't public. This is just like getting1483a project, but first ensures that given path is public.1484###1485opts = defaults opts,1486project_id : undefined1487path : undefined1488use_cache : true1489cb : required14901491if not opts.project_id?1492opts.cb("get_public_project: project_id must be defined")1493return14941495if not opts.path?1496opts.cb("get_public_project: path must be defined")1497return14981499# determine if path is public in given project, without using cache to determine paths; this *does* cache the result.1500@database.path_is_public1501project_id : opts.project_id1502path : opts.path1503cb : (err, is_public) =>1504if err1505opts.cb(err)1506return1507if is_public1508try1509opts.cb(undefined, await @projectControl(opts.project_id))1510catch err1511opts.cb(err)1512else1513# no1514opts.cb("path '#{opts.path}' of project with id '#{opts.project_id}' is not public")15151516mesg_copy_public_path_between_projects: (mesg) =>1517@touch()1518if not mesg.src_project_id?1519@error_to_client(id:mesg.id, error:"src_project_id must be defined")1520return1521if not mesg.target_project_id?1522@error_to_client(id:mesg.id, error:"target_project_id must be defined")1523return1524if not mesg.src_path?1525@error_to_client(id:mesg.id, error:"src_path must be defined")1526return1527project = undefined1528async.series([1529(cb) =>1530# ensure user can write to the target project1531access.user_has_write_access_to_project1532database : @database1533project_id : mesg.target_project_id1534account_id : @account_id1535account_groups : @groups1536cb : (err, result) =>1537if err1538cb(err)1539else if not result1540cb("user must have write access to target project #{mesg.target_project_id}")1541else1542cb()1543(cb) =>1544# Obviously, no need to check write access about the source project,1545# since we are only granting access to public files. This function1546# should ensure that the path is public:1547@get_public_project1548project_id : mesg.src_project_id1549path : mesg.src_path1550cb : (err, x) =>1551project = x1552cb(err)1553(cb) =>1554try1555await project.copyPath1556path : mesg.src_path1557target_project_id : mesg.target_project_id1558target_path : mesg.target_path1559overwrite_newer : mesg.overwrite_newer1560delete_missing : mesg.delete_missing1561timeout : mesg.timeout1562backup : mesg.backup1563public : true1564wait_until_done : true1565cb()1566catch err1567cb(err)1568], (err) =>1569if err1570@error_to_client(id:mesg.id, error:err)1571else1572@push_to_client(message.success(id:mesg.id))1573)15741575###1576Data Query1577###1578mesg_query: (mesg) =>1579dbg = @dbg("user_query")1580query = mesg.query1581if not query?1582@error_to_client(id:mesg.id, error:"malformed query")1583return1584# CRITICAL: don't enable this except for serious debugging, since it can result in HUGE output1585#dbg("account_id=#{@account_id} makes query='#{misc.to_json(query)}'")1586first = true1587if mesg.changes1588@_query_changefeeds ?= {}1589@_query_changefeeds[mesg.id] = true1590mesg_id = mesg.id1591@database.user_query1592client_id : @id1593account_id : @account_id1594query : query1595options : mesg.options1596changes : if mesg.changes then mesg_id1597cb : (err, result) =>1598if @closed # connection closed, so nothing further to do with this1599return1600if result?.action == 'close'1601err = 'close'1602if err1603dbg("user_query(query='#{misc.to_json(query)}') error:", err)1604if @_query_changefeeds?[mesg_id]1605delete @_query_changefeeds[mesg_id]1606@error_to_client(id:mesg_id, error:"#{err}") # Ensure err like Error('foo') can be JSON'd1607if mesg.changes and not first and @_query_changefeeds?[mesg_id]?1608dbg("changefeed got messed up, so cancel it:")1609@database.user_query_cancel_changefeed(id : mesg_id)1610else1611if mesg.changes and not first1612resp = result1613resp.id = mesg_id1614resp.multi_response = true1615else1616first = false1617resp = mesg1618resp.query = result1619@push_to_client(resp)16201621query_cancel_all_changefeeds: (cb) =>1622if not @_query_changefeeds?1623cb?(); return1624cnt = misc.len(@_query_changefeeds)1625if cnt == 01626cb?(); return1627dbg = @dbg("query_cancel_all_changefeeds")1628v = @_query_changefeeds1629dbg("cancel #{cnt} changefeeds")1630delete @_query_changefeeds1631f = (id, cb) =>1632dbg("cancel id=#{id}")1633@database.user_query_cancel_changefeed1634id : id1635cb : (err) =>1636if err1637dbg("FEED: warning #{id} -- error canceling a changefeed #{misc.to_json(err)}")1638else1639dbg("FEED: canceled changefeed -- #{id}")1640cb()1641async.map(misc.keys(v), f, (err) => cb?(err))16421643mesg_query_cancel: (mesg) =>1644if not @_query_changefeeds?[mesg.id]?1645# no such changefeed1646@success_to_client(id:mesg.id)1647else1648# actualy cancel it.1649if @_query_changefeeds?1650delete @_query_changefeeds[mesg.id]1651@database.user_query_cancel_changefeed1652id : mesg.id1653cb : (err, resp) =>1654if err1655@error_to_client(id:mesg.id, error:err)1656else1657mesg.resp = resp1658@push_to_client(mesg)165916601661###1662Support Tickets → Zendesk1663###1664mesg_create_support_ticket: (mesg) =>1665dbg = @dbg("mesg_create_support_ticket")1666dbg('deprecated')1667@error_to_client(id:mesg.id, error:'deprecated')16681669mesg_get_support_tickets: (mesg) =>1670# retrieves the support tickets the user with the current account_id1671dbg = @dbg("mesg_get_support_tickets")1672dbg('deprecated')1673@error_to_client(id:mesg.id, error:'deprecated')16741675###1676Stripe-integration billing code1677###1678handle_stripe_mesg: (mesg) =>1679try1680await @_stripe_client ?= new StripeClient(@)1681catch err1682@error_to_client(id:mesg.id, error:"${err}")1683return1684@_stripe_client.handle_mesg(mesg)16851686mesg_stripe_get_customer: (mesg) =>1687@handle_stripe_mesg(mesg)16881689mesg_stripe_create_source: (mesg) =>1690@handle_stripe_mesg(mesg)16911692mesg_stripe_delete_source: (mesg) =>1693@handle_stripe_mesg(mesg)16941695mesg_stripe_set_default_source: (mesg) =>1696@handle_stripe_mesg(mesg)16971698mesg_stripe_update_source: (mesg) =>1699@handle_stripe_mesg(mesg)17001701mesg_stripe_create_subscription: (mesg) =>1702@handle_stripe_mesg(mesg)17031704mesg_stripe_cancel_subscription: (mesg) =>1705@handle_stripe_mesg(mesg)17061707mesg_stripe_update_subscription: (mesg) =>1708@handle_stripe_mesg(mesg)17091710mesg_stripe_get_subscriptions: (mesg) =>1711@handle_stripe_mesg(mesg)17121713mesg_stripe_get_coupon: (mesg) =>1714@handle_stripe_mesg(mesg)17151716mesg_stripe_get_charges: (mesg) =>1717@handle_stripe_mesg(mesg)17181719mesg_stripe_get_invoices: (mesg) =>1720@handle_stripe_mesg(mesg)17211722mesg_stripe_admin_create_invoice_item: (mesg) =>1723@handle_stripe_mesg(mesg)17241725mesg_get_available_upgrades: (mesg) =>1726mesg.event = 'stripe_get_available_upgrades' # for backward compat1727@handle_stripe_mesg(mesg)17281729mesg_remove_all_upgrades: (mesg) =>1730mesg.event = 'stripe_remove_all_upgrades' # for backward compat1731@handle_stripe_mesg(mesg)17321733mesg_purchase_license: (mesg) =>1734try1735await @_stripe_client ?= new StripeClient(@)1736resp = await purchase_license(@account_id, mesg.info)1737@push_to_client(message.purchase_license_resp(id:mesg.id, resp:resp))1738catch err1739@error_to_client(id:mesg.id, error:err.toString())17401741# END stripe-related functionality17421743mesg_api_key: (mesg) =>1744try1745api_key = await legacyManageApiKey1746account_id : @account_id1747password : mesg.password1748action : mesg.action1749if api_key1750@push_to_client(message.api_key_info(id:mesg.id, api_key:api_key))1751else1752@success_to_client(id:mesg.id)1753catch err1754@error_to_client(id:mesg.id, error:err)17551756mesg_api_keys: (mesg) =>1757try1758response = await manageApiKeys1759account_id : @account_id1760password : mesg.password1761action : mesg.action1762project_id : mesg.project_id1763id : mesg.key_id1764expire : mesg.expire1765name : mesg.name1766@push_to_client(message.api_keys_response(id:mesg.id, response:response))1767catch err1768@error_to_client(id:mesg.id, error:err)17691770mesg_user_auth: (mesg) =>1771auth_token.get_user_auth_token1772database : @database1773account_id : @account_id # strictly not necessary yet... but good if user has to be signed in,1774# since more secure and we can rate limit attempts from a given user.1775user_account_id : mesg.account_id1776password : mesg.password1777cb : (err, auth_token) =>1778if err1779@error_to_client(id:mesg.id, error:err)1780else1781@push_to_client(message.user_auth_token(id:mesg.id, auth_token:auth_token))17821783mesg_revoke_auth_token: (mesg) =>1784auth_token.revoke_user_auth_token1785database : @database1786auth_token : mesg.auth_token1787cb : (err) =>1788if err1789@error_to_client(id:mesg.id, error:err)1790else1791@push_to_client(message.success(id:mesg.id))17921793# Receive and store in memory the latest metrics status from the client.1794mesg_metrics: (mesg) =>1795dbg = @dbg('mesg_metrics')1796dbg()1797if not mesg?.metrics1798return1799metrics = mesg.metrics1800#dbg('GOT: ', misc.to_json(metrics))1801if not misc.is_array(metrics)1802# client is messing with us...?1803return1804for metric in metrics1805if not misc.is_array(metric?.values)1806# what?1807return1808if metric.values.length == 01809return1810for v in metric.values1811if not misc.is_object(v?.labels)1812# what?1813return1814switch metric.type1815when 'gauge'1816metric.aggregator = 'average'1817else1818metric.aggregator = 'sum'18191820client_metrics[@id] = metrics1821#dbg('RECORDED: ', misc.to_json(client_metrics[@id]))18221823_check_project_access: (project_id, cb) =>1824if not @account_id?1825cb('you must be signed in to access project')1826return1827if not misc.is_valid_uuid_string(project_id)1828cb('project_id must be specified and valid')1829return1830access.user_has_write_access_to_project1831database : @database1832project_id : project_id1833account_groups : @groups1834account_id : @account_id1835cb : (err, result) =>1836if err1837cb(err)1838else if not result1839cb("must have write access")1840else1841cb()18421843_check_syncdoc_access: (string_id, cb) =>1844if not @account_id?1845cb('you must be signed in to access syncdoc')1846return1847if not typeof string_id == 'string' and string_id.length == 401848cb('string_id must be specified and valid')1849return1850@database._query1851query : "SELECT project_id FROM syncstrings"1852where : {"string_id = $::CHAR(40)" : string_id}1853cb : (err, results) =>1854if err1855cb(err)1856else if results.rows.length != 11857cb("no such syncdoc")1858else1859project_id = results.rows[0].project_id1860@_check_project_access(project_id, cb)18611862mesg_disconnect_from_project: (mesg) =>1863dbg = @dbg('mesg_disconnect_from_project')1864@_check_project_access mesg.project_id, (err) =>1865if err1866dbg("failed -- #{err}")1867@error_to_client(id:mesg.id, error:"unable to disconnect from project #{mesg.project_id} -- #{err}")1868else1869local_hub_connection.disconnect_from_project(mesg.project_id)1870@push_to_client(message.success(id:mesg.id))18711872mesg_touch_project: (mesg) =>1873dbg = @dbg('mesg_touch_project')1874async.series([1875(cb) =>1876dbg("checking conditions")1877@_check_project_access(mesg.project_id, cb)1878(cb) =>1879# IMPORTANT: do this ensure_connection_to_project *first*, since1880# it is critical always ensure this, and the @touch below gives1881# an error if done more than once per 45s, whereas we may want1882# to check much more frequently that we have a TCP connection1883# to the project.1884f = @database.ensure_connection_to_project1885if f?1886dbg("also create socket connection (so project can query db, etc.)")1887# We do NOT block on this -- it can take a while.1888f(mesg.project_id)1889cb()1890(cb) =>1891@touch1892project_id : mesg.project_id1893action : 'touch'1894cb : cb1895], (err) =>1896if err1897dbg("failed -- #{err}")1898@error_to_client(id:mesg.id, error:"unable to touch project #{mesg.project_id} -- #{err}")1899else1900@push_to_client(message.success(id:mesg.id))1901)19021903mesg_get_syncdoc_history: (mesg) =>1904dbg = @dbg('mesg_syncdoc_history')1905try1906dbg("checking conditions")1907# this raises an error if user does not have access1908await callback(@_check_syncdoc_access, mesg.string_id)1909# get the history1910history = await @database.syncdoc_history_async(mesg.string_id, mesg.patches)1911dbg("success!")1912@push_to_client(message.syncdoc_history(id:mesg.id, history:history))1913catch err1914dbg("failed -- #{err}")1915@error_to_client(id:mesg.id, error:"unable to get syncdoc history for string_id #{mesg.string_id} -- #{err}")19161917mesg_user_tracking: (mesg) =>1918dbg = @dbg("mesg_user_tracking")1919try1920if not @account_id1921throw Error("you must be signed in to record a tracking event")1922await record_user_tracking(@database, @account_id, mesg.evt, mesg.value)1923@push_to_client(message.success(id:mesg.id))1924catch err1925dbg("failed -- #{err}")1926@error_to_client(id:mesg.id, error:"unable to record user_tracking event #{mesg.evt} -- #{err}")19271928mesg_admin_reset_password: (mesg) =>1929dbg = @dbg("mesg_reset_password")1930dbg(mesg.email_address)1931try1932if not misc.is_valid_email_address(mesg.email_address)1933throw Error("invalid email address")1934await callback(@assert_user_is_in_group, 'admin')1935if not await callback2(@database.account_exists, {email_address : mesg.email_address})1936throw Error("no such account with email #{mesg.email_address}")1937# We now know that there is an account with this email address.1938# put entry in the password_reset uuid:value table with ttl of 24 hours.1939# NOTE: when users request their own reset, the ttl is 1 hour, but when we1940# as admins send one manually, they typically need more time, so 1 day instead.1941# We used 8 hours for a while and it is often not enough time.1942id = await callback2(@database.set_password_reset, {email_address : mesg.email_address, ttl:24*60*60});1943mesg.link = "/auth/password-reset/#{id}"1944@push_to_client(mesg)1945catch err1946dbg("failed -- #{err}")1947@error_to_client(id:mesg.id, error:"#{err}")19481949mesg_chatgpt: (mesg) =>1950dbg = @dbg("mesg_chatgpt")1951dbg(mesg.text)1952if not @account_id?1953@error_to_client(id:mesg.id, error:"not signed in")1954return1955if mesg.stream1956try1957stream = (text) =>1958@push_to_client(message.chatgpt_response(id:mesg.id, text:text, multi_response:text?))1959await 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)1960catch err1961dbg("failed -- #{err}")1962@error_to_client(id:mesg.id, error:"#{err}")1963else1964try1965output = 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)1966@push_to_client(message.chatgpt_response(id:mesg.id, text:output))1967catch err1968dbg("failed -- #{err}")1969@error_to_client(id:mesg.id, error:"#{err}")19701971# These are deprecated. Not the best approach.1972mesg_openai_embeddings_search: (mesg) =>1973@error_to_client(id:mesg.id, error:"openai_embeddings_search is DEPRECATED")19741975mesg_openai_embeddings_save: (mesg) =>1976@error_to_client(id:mesg.id, error:"openai_embeddings_save is DEPRECATED")19771978mesg_openai_embeddings_remove: (mesg) =>1979@error_to_client(id:mesg.id, error:"openai_embeddings_remove is DEPRECATED")19801981mesg_jupyter_execute: (mesg) =>1982dbg = @dbg("mesg_jupyter_execute")1983dbg(mesg.text)1984if not @account_id?1985@error_to_client(id:mesg.id, error:"not signed in")1986return1987try1988resp = await jupyter_execute(mesg)1989resp.id = mesg.id1990@push_to_client(message.jupyter_execute_response(resp))1991catch err1992dbg("failed -- #{err}")1993@error_to_client(id:mesg.id, error:"#{err}")19941995mesg_jupyter_kernels: (mesg) =>1996dbg = @dbg("mesg_jupyter_kernels")1997dbg(mesg.text)1998try1999@push_to_client(message.jupyter_kernels(id:mesg.id, kernels:await jupyter_kernels({project_id:mesg.project_id, account_id:@account_id})))2000catch err2001dbg("failed -- #{err}")2002@error_to_client(id:mesg.id, error:"#{err}")2003200420052006