Path: blob/master/src/packages/database/postgres-server-queries.coffee
1496 views
#########################################################################1# This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2# License: MS-RSL – see LICENSE.md for details3#########################################################################45###6PostgreSQL -- implementation of all the queries needed for the backend servers78These are all the non-reactive non-push queries, e.g., adding entries to logs,9checking on cookies, creating accounts and projects, etc.1011COPYRIGHT : (c) 2017 SageMath, Inc.12LICENSE : MS-RSL13###1415# limit for async.map or async.paralleLimit, esp. to avoid high concurrency when querying in parallel16MAP_LIMIT = 51718async = require('async')1920random_key = require("random-key")2122misc_node = require('@cocalc/backend/misc_node')23misc2_node = require('@cocalc/backend/misc')2425{defaults} = misc = require('@cocalc/util/misc')26required = defaults.required2728# IDK why, but if that import line is down below, where the other "./postgres/*" imports are, building manage29# fails with: remember-me.ts(15,31): error TS2307: Cannot find module 'async-await-utils/hof' or its corresponding type declarations.30{get_remember_me} = require('./postgres/remember-me')3132{SCHEMA, DEFAULT_QUOTAS, PROJECT_UPGRADES, COMPUTE_STATES, RECENT_TIMES, RECENT_TIMES_KEY, site_settings_conf} = require('@cocalc/util/schema')3334{ quota } = require("@cocalc/util/upgrades/quota")3536PROJECT_GROUPS = misc.PROJECT_GROUPS3738read = require('read')3940{PROJECT_COLUMNS, one_result, all_results, count_result, expire_time} = require('./postgres-base')4142# TODO is set_account_info_if_possible used here?!43{is_paying_customer, set_account_info_if_possible} = require('./postgres/account-queries')44{getStripeCustomerId, syncCustomer} = require('./postgres/stripe')4546{site_license_usage_stats, projects_using_site_license, number_of_projects_using_site_license} = require('./postgres/site-license/analytics')47{update_site_license_usage_log} = require('./postgres/site-license/usage-log')48{site_license_public_info} = require('./postgres/site-license/public')49{site_license_manager_set} = require('./postgres/site-license/manager')50{matching_site_licenses, manager_site_licenses} = require('./postgres/site-license/search')51{project_datastore_set, project_datastore_get, project_datastore_del} = require('./postgres/project-queries')52{permanently_unlink_all_deleted_projects_of_user, unlink_old_deleted_projects} = require('./postgres/delete-projects')53{get_all_public_paths, unlist_all_public_paths} = require('./postgres/public-paths')54{get_personal_user} = require('./postgres/personal')55{set_passport_settings, get_passport_settings, get_all_passport_settings, get_all_passport_settings_cached, create_passport, passport_exists, update_account_and_passport, _passport_key} = require('./postgres/passport')56{projects_that_need_to_be_started} = require('./postgres/always-running');57{calc_stats} = require('./postgres/stats')58{getServerSettings, resetServerSettingsCache, getPassportsCached, setPassportsCached} = require('@cocalc/database/settings/server-settings');59{pii_expire} = require("./postgres/pii")60passwordHash = require("@cocalc/backend/auth/password-hash").default;61registrationTokens = require('./postgres/registration-tokens').default;62{updateUnreadMessageCount} = require('./postgres/messages');63centralLog = require('./postgres/central-log').default;6465stripe_name = require('@cocalc/util/stripe/name').default;666768exports.extend_PostgreSQL = (ext) -> class PostgreSQL extends ext69# write an event to the central_log table70log: (opts) =>71opts = defaults opts,72event : required # string73value : required # object74cb : undefined75try76await centralLog(opts)77opts.cb?()78catch err79opts.cb?(err)8081uncaught_exception: (err) =>82# call when things go to hell in some unexpected way; at least83# we attempt to record this in the database...84try85@log86event : 'uncaught_exception'87value : {error:"#{err}", stack:"#{err.stack}", host:require('os').hostname()}88catch e89# IT IS CRITICAL THAT uncaught_exception not raise an exception, since if it90# did then we would hit a horrible infinite loop!9192# dump a range of data from the central_log table93get_log: (opts) =>94opts = defaults opts,95start : undefined # if not given start at beginning of time96end : undefined # if not given include everything until now97log : 'central_log' # which table to query98event : undefined99where : undefined # if given, restrict to records with the given json100# containment, e.g., {account_id:'...'}, only returns101# entries whose value has the given account_id.102cb : required103@_query104query : "SELECT * FROM #{opts.log}"105where :106'time >= $::TIMESTAMP' : opts.start107'time <= $::TIMESTAMP' : opts.end108'event = $::TEXT' : opts.event109'value @> $::JSONB' : opts.where110cb : all_results(opts.cb)111112# Return every entry x in central_log in the given period of time for113# which x.event==event and x.value.account_id == account_id.114get_user_log: (opts) =>115opts = defaults opts,116start : undefined117end : undefined # if not given include everything until now118event : 'successful_sign_in'119account_id : required120cb : required121@get_log122start : opts.start123end : opts.end124event : opts.event125where : {account_id: opts.account_id}126cb : opts.cb127128log_client_error: (opts) =>129opts = defaults opts,130event : 'event'131error : 'error'132account_id : undefined133cb : undefined134# get rid of the entry in 30 days135expire = misc.expire_time(30 * 24 * 60 * 60)136@_query137query : 'INSERT INTO client_error_log'138values :139'id :: UUID' : misc.uuid()140'event :: TEXT' : opts.event141'error :: TEXT' : opts.error142'account_id :: UUID' : opts.account_id143'time :: TIMESTAMP' : 'NOW()'144'expire :: TIMESTAMP' : expire145cb : opts.cb146147webapp_error: (opts) =>148opts = defaults opts,149account_id : undefined150name : undefined151message : undefined152comment : undefined153stacktrace : undefined154file : undefined155path : undefined156lineNumber : undefined157columnNumber : undefined158severity : undefined159browser : undefined160mobile : undefined161responsive : undefined162user_agent : undefined163smc_version : undefined164build_date : undefined165smc_git_rev : undefined166uptime : undefined167start_time : undefined168id : undefined # ignored169cb : undefined170# get rid of the entry in 30 days171expire = misc.expire_time(30 * 24 * 60 * 60)172@_query173query : 'INSERT INTO webapp_errors'174values :175'id :: UUID' : misc.uuid()176'account_id :: UUID' : opts.account_id177'name :: TEXT' : opts.name178'message :: TEXT' : opts.message179'comment :: TEXT' : opts.comment180'stacktrace :: TEXT' : opts.stacktrace181'file :: TEXT' : opts.file182'path :: TEXT' : opts.path183'lineNumber :: INTEGER' : opts.lineNumber184'columnNumber :: INTEGER' : opts.columnNumber185'severity :: TEXT' : opts.severity186'browser :: TEXT' : opts.browser187'mobile :: BOOLEAN' : opts.mobile188'responsive :: BOOLEAN' : opts.responsive189'user_agent :: TEXT' : opts.user_agent190'smc_version :: TEXT' : opts.smc_version191'build_date :: TEXT' : opts.build_date192'smc_git_rev :: TEXT' : opts.smc_git_rev193'uptime :: TEXT' : opts.uptime194'start_time :: TIMESTAMP' : opts.start_time195'time :: TIMESTAMP' : 'NOW()'196'expire :: TIMESTAMP' : expire197cb : opts.cb198199get_client_error_log: (opts) =>200opts = defaults opts,201start : undefined # if not given start at beginning of time202end : undefined # if not given include everything until now203event : undefined204cb : required205opts.log = 'client_error_log'206@get_log(opts)207208set_server_setting: (opts) =>209opts = defaults opts,210name : required211value : required212readonly : undefined # boolean. if yes, that value is not controlled via any UI213cb : required214async.series([215(cb) =>216values =217'name::TEXT' : opts.name218'value::TEXT' : opts.value219if opts.readonly?220values.readonly = !!opts.readonly221@_query222query : 'INSERT INTO server_settings'223values : values224conflict : 'name'225cb : cb226# also set a timestamp227(cb) =>228@_query229query : 'INSERT INTO server_settings'230values :231'name::TEXT' : '_last_update'232'value::TEXT' : (new Date()).toISOString()233conflict : 'name'234cb : cb235], (err) =>236# clear the cache no matter what (e.g., server_settings might have partly changed then errored)237@reset_server_settings_cache()238opts.cb(err)239)240241reset_server_settings_cache: =>242resetServerSettingsCache()243244get_server_setting: (opts) =>245opts = defaults opts,246name : required247cb : required248@_query249query : 'SELECT value FROM server_settings'250where :251"name = $::TEXT" : opts.name252cb : one_result('value', opts.cb)253254get_server_settings_cached: (opts) =>255opts = defaults opts,256cb: required257try258opts.cb(undefined, await getServerSettings())259catch err260opts.cb(err)261262get_site_settings: (opts) =>263opts = defaults opts,264cb : required # (err, settings)265@_query266query : 'SELECT name, value FROM server_settings'267cache : true268where :269"name = ANY($)" : misc.keys(site_settings_conf)270cb : (err, result) =>271if err272opts.cb(err)273else274x = {}275for k in result.rows276if k.name == 'commercial' and k.value in ['true', 'false'] # backward compatibility277k.value = eval(k.value)278x[k.name] = k.value279opts.cb(undefined, x)280281server_settings_synctable: (opts={}) =>282opts.table = 'server_settings'283return @synctable(opts)284285set_passport_settings: (opts) =>286opts = defaults opts,287strategy : required288conf : required289info : undefined290cb : required291return await set_passport_settings(@, opts)292293get_passport_settings: (opts) =>294opts = defaults opts,295strategy : required296return await get_passport_settings(@, opts)297298get_all_passport_settings: () =>299return await get_all_passport_settings(@)300301get_all_passport_settings_cached: () =>302return await get_all_passport_settings_cached(@)303304create_passport: (opts) =>305return await create_passport(@, opts)306307passport_exists: (opts) =>308return await passport_exists(@, opts)309310update_account_and_passport: (opts) =>311return await update_account_and_passport(@, opts)312313###314Creating an account using SSO only.315This needs to be rewritten in @cocalc/server like316all the other account creation. This is horrible317because318###319create_sso_account: (opts={}) =>320opts = defaults opts,321first_name : undefined322last_name : undefined323324created_by : undefined # ip address of computer creating this account325326email_address : undefined327password_hash : undefined328lti_id : undefined # 2-tuple <string[]>[iss, user_id]329330passport_strategy : undefined331passport_id : undefined332passport_profile : undefined333usage_intent : undefined334cb : required # cb(err, account_id)335336dbg = @_dbg("create_sso_account(#{opts.first_name}, #{opts.last_name}, #{opts.lti_id}, #{opts.email_address}, #{opts.passport_strategy}, #{opts.passport_id}), #{opts.usage_intent}")337dbg()338339for name in ['first_name', 'last_name']340if opts[name]341test = misc2_node.is_valid_username(opts[name])342if test?343opts.cb("#{name} not valid: #{test}")344return345346if opts.email_address # canonicalize the email address, if given347opts.email_address = misc.lower_email_address(opts.email_address)348349account_id = misc.uuid()350351passport_key = undefined352if opts.passport_strategy?353# This is to make it impossible to accidentally create two accounts with the same passport354# due to calling create_account twice at once. See TODO below about changing schema.355# This should be enough for now since a given user only makes their account through a single356# server via the persistent websocket...357@_create_account_passport_keys ?= {}358passport_key = _passport_key(strategy:opts.passport_strategy, id:opts.passport_id)359last = @_create_account_passport_keys[passport_key]360if last? and new Date() - last <= 60*1000361opts.cb("recent attempt to make account with this passport strategy")362return363@_create_account_passport_keys[passport_key] = new Date()364365async.series([366(cb) =>367if not opts.passport_strategy?368cb(); return369dbg("verify that no account with passport (strategy='#{opts.passport_strategy}', id='#{opts.passport_id}') already exists")370# **TODO:** need to make it so insertion into the table still would yield an error due to371# unique constraint; this will require probably moving the passports372# object to a separate table. This is important, since this is exactly the place where373# a race condition might cause trouble!374@passport_exists375strategy : opts.passport_strategy376id : opts.passport_id377cb : (err, account_id) ->378if err379cb(err)380else if account_id381cb("account with email passport strategy '#{opts.passport_strategy}' and id '#{opts.passport_id}' already exists")382else383cb()384(cb) =>385dbg("create the actual account")386@_query387query : "INSERT INTO accounts"388values :389'account_id :: UUID' : account_id390'first_name :: TEXT' : opts.first_name391'last_name :: TEXT' : opts.last_name392'lti_id :: TEXT[]' : opts.lti_id393'created :: TIMESTAMP' : new Date()394'created_by :: INET' : opts.created_by395'password_hash :: CHAR(173)' : opts.password_hash396'email_address :: TEXT' : opts.email_address397'sign_up_usage_intent :: TEXT': opts.usage_intent398cb : cb399(cb) =>400if opts.passport_strategy?401dbg("add passport authentication strategy")402@create_passport403account_id : account_id404strategy : opts.passport_strategy405id : opts.passport_id406profile : opts.passport_profile407cb : cb408else409cb()410], (err) =>411if err412dbg("error creating account -- #{err}")413opts.cb(err)414else415dbg("successfully created account")416opts.cb(undefined, account_id)417)418419is_admin: (opts) =>420opts = defaults opts,421account_id : required422cb : required423@_query424query : "SELECT groups FROM accounts"425where : 'account_id = $::UUID':opts.account_id426cache : true427cb : one_result 'groups', (err, groups) =>428opts.cb(err, groups? and 'admin' in groups)429430user_is_in_group: (opts) =>431opts = defaults opts,432account_id : required433group : required434cb : required435@_query436query : "SELECT groups FROM accounts"437where : 'account_id = $::UUID':opts.account_id438cache : true439cb : one_result 'groups', (err, groups) =>440opts.cb(err, groups? and opts.group in groups)441442make_user_admin: (opts) =>443opts = defaults opts,444account_id : undefined445email_address : undefined446cb : required447if not opts.account_id? and not opts.email_address?448opts.cb?("account_id or email_address must be given")449return450async.series([451(cb) =>452if opts.account_id?453cb()454else455@get_account456email_address : opts.email_address457columns : ['account_id']458cb : (err, x) =>459if err460cb(err)461else if not x?462cb("no such email address")463else464opts.account_id = x.account_id465cb()466(cb) =>467@clear_cache() # caching is mostly for permissions so this is exactly when it would be nice to clear it.468@_query469query : "UPDATE accounts"470where : 'account_id = $::UUID':opts.account_id471set :472groups : ['admin']473cb : cb474], opts.cb)475476count_accounts_created_by: (opts) =>477opts = defaults opts,478ip_address : required479age_s : required480cb : required481@_count482table : 'accounts'483where :484"created_by = $::INET" : opts.ip_address485"created >= $::TIMESTAMP" : misc.seconds_ago(opts.age_s)486cb : opts.cb487488# Completely delete the given account from the database. This doesn't489# do any sort of cleanup of things associated with the account! There490# is no reason to ever use this, except for testing purposes.491delete_account: (opts) =>492opts = defaults opts,493account_id : required494cb : required495if not @_validate_opts(opts) then return496@_query497query : "DELETE FROM accounts"498where : "account_id = $::UUID" : opts.account_id499cb : opts.cb500501# Mark the account as deleted, thus freeing up the email502# address for use by another account, etc. The actual503# account entry remains in the database, since it may be504# referred to by many other things (projects, logs, etc.).505# However, the deleted field is set to true, so the account506# is excluded from user search.507# TODO: rewritten in packages/server/accounts/delete.ts508mark_account_deleted: (opts) =>509opts = defaults opts,510account_id : undefined511email_address : undefined512cb : required513if not opts.account_id? and not opts.email_address?514opts.cb("one of email address or account_id must be specified -- make sure you are signed in")515return516517query = undefined518email_address = undefined519async.series([520(cb) =>521if opts.account_id?522cb()523else524@account_exists525email_address : opts.email_address526cb : (err, account_id) =>527if err528cb(err)529else if not account_id530cb("no such email address known")531else532opts.account_id = account_id533cb()534(cb) =>535@_query536query : "SELECT email_address FROM accounts"537where : "account_id = $::UUID" : opts.account_id538cb : one_result 'email_address', (err, x) =>539email_address = x; cb(err)540(cb) =>541@_query542query : "UPDATE accounts"543set :544"deleted::BOOLEAN" : true545"email_address_before_delete::TEXT" : email_address546"email_address" : null547"passports" : null548where : "account_id = $::UUID" : opts.account_id549cb : cb550], opts.cb)551552account_exists: (opts) =>553opts = defaults opts,554email_address : required555cb : required # cb(err, account_id or undefined) -- actual account_id if it exists; err = problem with db connection...556@_query557query : 'SELECT account_id FROM accounts'558where : "email_address = $::TEXT" : opts.email_address559cb : one_result('account_id', opts.cb)560561# set an account creation action, or return all of them for the given email address562account_creation_actions: (opts) =>563opts = defaults opts,564email_address : required565action : undefined # if given, adds this action; if not, returns all non-expired actions566ttl : 60*60*24*14 # add action with this ttl in seconds (default: 2 weeks)567cb : required # if ttl not given cb(err, [array of actions])568if opts.action?569# add action570@_query571query : 'INSERT INTO account_creation_actions'572values :573'id :: UUID' : misc.uuid()574'email_address :: TEXT' : opts.email_address575'action :: JSONB' : opts.action576'expire :: TIMESTAMP' : expire_time(opts.ttl)577cb : opts.cb578else579# query for actions580@_query581query : 'SELECT action FROM account_creation_actions'582where :583'email_address = $::TEXT' : opts.email_address584'expire >= $::TIMESTAMP' : new Date()585cb : all_results('action', opts.cb)586587account_creation_actions_success: (opts) =>588opts = defaults opts,589account_id : required590cb : required591@_query592query : 'UPDATE accounts'593set :594'creation_actions_done::BOOLEAN' : true595where :596'account_id = $::UUID' : opts.account_id597cb : opts.cb598599# DEPRECATED: use import accountCreationActions from "@cocalc/server/accounts/account-creation-actions"; instead!!!!600do_account_creation_actions: (opts) =>601opts = defaults opts,602email_address : required603account_id : required604cb : required605dbg = @_dbg("do_account_creation_actions(email_address='#{opts.email_address}')")606dbg("**DEPRECATED!** This will miss doing important things, e.g., creating initial project.")607@account_creation_actions608email_address : opts.email_address609cb : (err, actions) =>610if err611opts.cb(err); return612f = (action, cb) =>613dbg("account_creation_actions: action = #{misc.to_json(action)}")614if action.action == 'add_to_project'615@add_user_to_project616project_id : action.project_id617account_id : opts.account_id618group : action.group619cb : (err) =>620if err621dbg("Error adding user to project: #{err}")622cb(err)623else624dbg("ERROR: skipping unknown action -- #{action.action}")625# also store in database so we can look into this later.626@log627event : 'unknown_action'628value :629error : "unknown_action"630action : action631account_id : opts.account_id632host : require('os').hostname()633cb()634async.map actions, f, (err) =>635if not err636@account_creation_actions_success637account_id : opts.account_id638cb : opts.cb639else640opts.cb(err)641642verify_email_create_token: (opts) => # has been rewritten in backend/email/verify.ts643opts = defaults opts,644account_id : required645cb : undefined646647locals =648email_address : undefined649token : undefined650old_challenge : undefined651652async.series([653(cb) =>654@_query655query : "SELECT email_address, email_address_challenge FROM accounts"656where : "account_id = $::UUID" : opts.account_id657cb : one_result (err, x) =>658locals.email_address = x?.email_address659locals.old_challenge = x?.email_address_challenge660cb(err)661(cb) =>662# TODO maybe expire tokens after some time663if locals.old_challenge?664old = locals.old_challenge665# return the same token if there is one for the same email666if old.token? and old.email == locals.email_address667locals.token = locals.old_challenge.token668cb()669return670671{generate} = require("random-key")672locals.token = generate(16).toLowerCase()673data =674email : locals.email_address675token : locals.token676time : new Date()677678@_query679query : "UPDATE accounts"680set :681'email_address_challenge::JSONB' : data682where :683"account_id = $::UUID" : opts.account_id684cb : cb685], (err) ->686opts.cb?(err, locals)687)688689690verify_email_check_token: (opts) => # rewritten in server/auth/redeem-verify-email.ts691opts = defaults opts,692email_address : required693token : required694cb : undefined695696locals =697account_id : undefined698email_address_challenge : undefined699700async.series([701(cb) =>702@get_account703email_address : opts.email_address704columns : ['account_id', 'email_address_challenge']705cb : (err, x) =>706if err707cb(err)708else if not x?709cb("no such email address")710else711locals.account_id = x.account_id712locals.email_address_challenge = x.email_address_challenge713cb()714(cb) =>715if not locals.email_address_challenge?716@is_verified_email717email_address : opts.email_address718cb : (err, verified) ->719if not err and verified720cb("This email address is already verified.")721else722cb("For this email address no account verification is setup.")723724else if locals.email_address_challenge.email != opts.email_address725cb("The account's email address does not match the token's email address.")726727else if locals.email_address_challenge.time < misc.hours_ago(24)728cb("The account verification token is no longer valid. Get a new one!")729730else731if locals.email_address_challenge.token == opts.token732cb()733else734cb("Provided token does not match.")735(cb) =>736# we're good, save it737@_query738query : "UPDATE accounts"739jsonb_set :740email_address_verified:741"#{opts.email_address}" : new Date()742where : "account_id = $::UUID" : locals.account_id743cb : cb744(cb) =>745# now delete the token746@_query747query : 'UPDATE accounts'748set :749'email_address_challenge::JSONB' : null750where :751"account_id = $::UUID" : locals.account_id752cb : cb753], opts.cb)754755# returns the email address and whether or not it is verified756verify_email_get: (opts) =>757opts = defaults opts,758account_id : required759cb : undefined760@_query761query : "SELECT email_address, email_address_verified FROM accounts"762where : "account_id = $::UUID" : opts.account_id763cb : one_result (err, x) ->764opts.cb?(err, x)765766# answers the question as cb(null, [true or false])767is_verified_email: (opts) => # rewritten in server/auth/redeem-verify-email.ts768opts = defaults opts,769email_address : required770cb : required771@get_account772email_address : opts.email_address773columns : ['email_address_verified']774cb : (err, x) =>775if err776opts.cb(err)777else if not x?778opts.cb("no such email address")779else780verified = !!x.email_address_verified?[opts.email_address]781opts.cb(undefined, verified)782783###784Auxiliary billing related queries785###786get_coupon_history: (opts) =>787opts = defaults opts,788account_id : required789cb : undefined790@_dbg("Getting coupon history")791@_query792query : "SELECT coupon_history FROM accounts"793where : 'account_id = $::UUID' : opts.account_id794cb : one_result("coupon_history", opts.cb)795796update_coupon_history: (opts) =>797opts = defaults opts,798account_id : required799coupon_history : required800cb : undefined801@_dbg("Setting to #{opts.coupon_history}")802@_query803query : 'UPDATE accounts'804set : 'coupon_history::JSONB' : opts.coupon_history805where : 'account_id = $::UUID' : opts.account_id806cb : opts.cb807808###809Querying for searchable information about accounts.810###811account_ids_to_usernames: (opts) =>812opts = defaults opts,813account_ids : required814cb : required # (err, mapping {account_id:{first_name:?, last_name:?}})815if not @_validate_opts(opts) then return816if opts.account_ids.length == 0 # easy special case -- don't waste time on a db query817opts.cb(undefined, [])818return819@_query820query : 'SELECT account_id, first_name, last_name FROM accounts'821where : 'account_id = ANY($::UUID[])' : opts.account_ids822cb : (err, result) =>823if err824opts.cb(err)825else826v = misc.dict(([r.account_id, {first_name:r.first_name, last_name:r.last_name}] for r in result.rows))827# fill in unknown users (should never be hit...)828for id in opts.account_ids829if not v[id]?830v[id] = {first_name:undefined, last_name:undefined}831opts.cb(err, v)832833_account_where: (opts) =>834# account_id > email_address > lti_id835if opts.account_id836return {"account_id = $::UUID" : opts.account_id}837else if opts.email_address838return {"email_address = $::TEXT" : opts.email_address}839else if opts.lti_id840return {"lti_id = $::TEXT[]" : opts.lti_id}841else842throw Error("postgres-server-queries::_account_where neither account_id, nor email_address, nor lti_id specified and nontrivial")843844get_account: (opts) =>845opts = defaults opts,846email_address : undefined # provide one of email, account_id, or lti_id (pref is account_id, then email_address, then lti_id)847account_id : undefined848lti_id : undefined849columns : ['account_id',850'password_hash',851'password_is_set', # true or false, depending on whether a password is set (since don't send password_hash to user!)852'first_name',853'last_name',854'email_address',855'evaluate_key',856'autosave',857'terminal',858'editor_settings',859'other_settings',860'groups',861'passports'862]863cb : required864if not @_validate_opts(opts) then return865columns = misc.copy(opts.columns)866if 'password_is_set' in columns867if 'password_hash' not in columns868remove_password_hash = true869columns.push('password_hash')870misc.remove(columns, 'password_is_set')871password_is_set = true872@_query873query : "SELECT #{columns.join(',')} FROM accounts"874where : @_account_where(opts)875cb : one_result (err, z) =>876if err877opts.cb(err)878else if not z?879opts.cb("no such account")880else881if password_is_set882z.password_is_set = !!z.password_hash883if remove_password_hash884delete z.password_hash885for c in columns886if not z[c]? # for same semantics as rethinkdb... (for now)887delete z[c]888opts.cb(undefined, z)889890# check whether or not a user is banned891is_banned_user: (opts) =>892opts = defaults opts,893email_address : undefined894account_id : undefined895cb : required # cb(err, true if banned; false if not banned)896if not @_validate_opts(opts) then return897@_query898query : 'SELECT banned FROM accounts'899where : @_account_where(opts)900cb : one_result('banned', (err, banned) => opts.cb(err, !!banned))901902_touch_account: (account_id, cb) =>903if @_throttle('_touch_account', 120, account_id)904cb()905return906@_query907query : 'UPDATE accounts'908set : {last_active: 'NOW()'}909where : "account_id = $::UUID" : account_id910cb : cb911912_touch_project: (project_id, account_id, cb) =>913if @_throttle('_user_touch_project', 60, project_id, account_id)914cb()915return916NOW = new Date()917@_query918query : "UPDATE projects"919set : {last_edited : NOW}920jsonb_merge : {last_active:{"#{account_id}":NOW}}921where : "project_id = $::UUID" : project_id922cb : cb923924# Indicate activity by a user, possibly on a specific project, and925# then possibly on a specific path in that project.926touch: (opts) =>927opts = defaults opts,928account_id : required929project_id : undefined930path : undefined931action : 'edit'932ttl_s : 50 # min activity interval; calling this function with same input again within this interval is ignored933cb : undefined934if opts.ttl_s935if @_throttle('touch', opts.ttl_s, opts.account_id, opts.project_id, opts.path, opts.action)936opts.cb?()937return938939now = new Date()940async.parallel([941(cb) =>942@_touch_account(opts.account_id, cb)943(cb) =>944if not opts.project_id?945cb(); return946@_touch_project(opts.project_id, opts.account_id, cb)947(cb) =>948if not opts.path? or not opts.project_id?949cb(); return950@record_file_use(project_id:opts.project_id, path:opts.path, action:opts.action, account_id:opts.account_id, cb:cb)951], (err)->opts.cb?(err))952953954# Invalidate all outstanding remember me cookies for the given account by955# deleting them from the remember_me key:value store.956invalidate_all_remember_me: (opts) =>957opts = defaults opts,958account_id : undefined959email_address : undefined960cb : undefined961if not @_validate_opts(opts) then return962@_query963query : 'DELETE FROM remember_me'964where : @_account_where(opts)965cb : opts.cb966967# Get remember me cookie with given hash. If it has expired,968# **get back undefined instead**. (Actually deleting expired).969# We use retry_until_success, since an intermittent database970# reconnect can result in a cb error that will very soon971# work fine, and we don't to flat out sign the client out972# just because of this.973get_remember_me: (opts) =>974opts = defaults opts,975hash : required976cache : true977cb : required # cb(err, signed_in_message | undefined)978account_id = undefined979try980account_id = await get_remember_me(@, opts.hash, opts.cache)981catch err982opts.cb(err)983return984if account_id985opts.cb(undefined, {event:"signed_in", account_id:account_id})986else987opts.cb()988989delete_remember_me: (opts) =>990opts = defaults opts,991hash : required992cb : undefined993@_query994query : 'DELETE FROM remember_me'995where :996'hash = $::TEXT' : opts.hash.slice(0,127)997cb : opts.cb998999# ASYNC FUNCTION1000get_personal_user: () =>1001return await get_personal_user(@)10021003###1004# Changing password/email, etc. sensitive info about a user1005###10061007# Change the password for the given account.1008change_password: (opts={}) =>1009opts = defaults opts,1010account_id : required1011password_hash : required1012invalidate_remember_me : true1013cb : required1014if not @_validate_opts(opts) then return1015if opts.password_hash.length > 1731016opts.cb("password_hash must be at most 173 characters")1017return1018async.series([ # don't do in parallel -- don't kill remember_me if password failed!1019(cb) =>1020@_query1021query : 'UPDATE accounts'1022set : {password_hash : opts.password_hash}1023where : @_account_where(opts)1024cb : cb1025(cb) =>1026if opts.invalidate_remember_me1027@invalidate_all_remember_me1028account_id : opts.account_id1029cb : cb1030else1031cb()1032], opts.cb)10331034# Reset Password MEANT FOR INTERACTIVE USE -- if password is not given, will prompt for it.1035reset_password: (opts) =>1036opts = defaults opts,1037email_address : undefined1038account_id : undefined1039password : undefined1040random : true # if true (the default), will generate and print a random password.1041cb : undefined1042dbg = @_dbg("reset_password")1043async.series([1044(cb) =>1045if opts.account_id?1046cb()1047return1048@get_account1049email_address : opts.email_address1050columns : ['account_id']1051cb : (err, data) =>1052opts.account_id = data?.account_id1053cb(err)1054(cb) =>1055if opts.password?1056cb()1057return1058if opts.random1059require('crypto').randomBytes 16, (err, buffer) =>1060opts.password = buffer.toString('hex')1061cb()1062return1063read {prompt:'Password: ', silent:true}, (err, passwd) =>1064opts.passwd0 = passwd; cb(err)1065(cb) =>1066if opts.password?1067cb()1068return1069read {prompt:'Retype password: ', silent:true}, (err, passwd1) =>1070if err1071cb(err)1072else1073if passwd1 != opts.passwd01074cb("Passwords do not match.")1075else1076opts.password = passwd11077cb()1078(cb) =>1079# change the user's password in the database.1080@change_password1081account_id : opts.account_id1082password_hash : passwordHash(opts.password)1083cb : cb1084], (err) =>1085if err1086console.warn("Error -- #{err}")1087else1088console.log("Password changed for #{opts.email_address}")1089if opts.random1090console.log("Random Password:\n\n\t\t#{opts.password}\n\n")1091opts.cb?(err)1092)10931094# Change the email address, unless the email_address we're changing to is already taken.1095# If there is a stripe customer ID, we also call the update process to maybe sync the changed email address1096change_email_address: (opts={}) =>1097opts = defaults opts,1098account_id : required1099email_address : required1100stripe : required1101cb : required1102if not @_validate_opts(opts) then return1103async.series([1104(cb) =>1105@account_exists1106email_address : opts.email_address1107cb : (err, exists) =>1108if err1109cb(err)1110return1111if exists1112cb("email_already_taken")1113return1114cb()1115(cb) =>1116@_query1117query : 'UPDATE accounts'1118set : {email_address: opts.email_address}1119where : @_account_where(opts)1120cb : cb1121(cb) =>1122@_query1123query : "SELECT stripe_customer_id FROM accounts"1124where : "account_id = $::UUID" : opts.account_id1125cb : one_result (err, x) =>1126if err1127cb(err)1128return1129if x.stripe_customer_id1130try1131await syncCustomer1132account_id : opts.account_id1133stripe : opts.stripe1134customer_id : x.stripe_customer_id1135cb()1136catch err1137cb(err)1138else1139cb()1140], (err) =>1141opts.cb(err)1142)11431144###1145Password reset1146###1147set_password_reset: (opts) =>1148opts = defaults opts,1149email_address : required1150ttl : required1151cb : required # cb(err, uuid)1152id = misc.uuid()1153@_query1154query : "INSERT INTO password_reset"1155values :1156"id :: UUID" : id1157"email_address :: TEXT" : opts.email_address1158"expire :: TIMESTAMP" : expire_time(opts.ttl)1159cb : (err) =>1160opts.cb(err, id)11611162get_password_reset: (opts) =>1163opts = defaults opts,1164id : required1165cb : required # cb(err, true if allowed and false if not)1166@_query1167query : 'SELECT expire, email_address FROM password_reset'1168where : 'id = $::UUID': opts.id1169cb : one_result('email_address', opts.cb)11701171delete_password_reset: (opts) =>1172opts = defaults opts,1173id : required1174cb : required # cb(err, true if allowed and false if not)1175@_query1176query : 'DELETE FROM password_reset'1177where : 'id = $::UUID': opts.id1178cb : opts.cb11791180record_password_reset_attempt: (opts) =>1181opts = defaults opts,1182email_address : required1183ip_address : required1184ttl : required1185cb : required # cb(err)1186@_query1187query : 'INSERT INTO password_reset_attempts'1188values :1189"id :: UUID" : misc.uuid()1190"email_address :: TEXT " : opts.email_address1191"ip_address :: INET" : opts.ip_address1192"time :: TIMESTAMP" : "NOW()"1193"expire :: TIMESTAMP" : expire_time(opts.ttl)1194cb : opts.cb11951196count_password_reset_attempts: (opts) =>1197opts = defaults opts,1198email_address : undefined # must give one of email_address or ip_address1199ip_address : undefined1200age_s : required # at most this old1201cb : required # cb(err)1202@_query1203query : 'SELECT COUNT(*) FROM password_reset_attempts'1204where :1205'time >= $::TIMESTAMP' : misc.seconds_ago(opts.age_s)1206'email_address = $::TEXT ' : opts.email_address1207'ip_address = $::INET ' : opts.ip_address1208cb : count_result(opts.cb)12091210###1211Tracking file access12121213log_file_access is throttled in each server, in the sense that1214if it is called with the same input within a minute, those1215subsequent calls are ignored. Of course, if multiple servers1216are recording file_access then there can be more than one1217entry per minute.1218###1219log_file_access: (opts) =>1220opts = defaults opts,1221project_id : required1222account_id : required1223filename : required1224cb : undefined1225if not @_validate_opts(opts) then return1226if @_throttle('log_file_access', 60, opts.project_id, opts.account_id, opts.filename)1227opts.cb?()1228return12291230# If expire no pii expiration is set, use 1 year as a fallback1231expire = await pii_expire() ? expire_time(365*24*60*60)12321233@_query1234query : 'INSERT INTO file_access_log'1235values :1236'id :: UUID ' : misc.uuid()1237'project_id :: UUID ' : opts.project_id1238'account_id :: UUID ' : opts.account_id1239'filename :: TEXT ' : opts.filename1240'time :: TIMESTAMP' : 'NOW()'1241'expire :: TIMESTAMP' : expire1242cb : opts.cb12431244###1245Efficiently get all files access times subject to various constraints...12461247NOTE: this was not available in RethinkDB version (too painful to implement!), but here it is,1248easily sliceable in any way. This could be VERY useful for users!1249###1250get_file_access: (opts) =>1251opts = defaults opts,1252start : undefined # start time1253end : undefined # end time1254project_id : undefined1255account_id : undefined1256filename : undefined1257cb : required1258@_query1259query : 'SELECT project_id, account_id, filename, time FROM file_access_log'1260where :1261'time >= $::TIMESTAMP' : opts.start1262'time <= $::TIMESTAMP' : opts.end1263'project_id = $::UUID' : opts.project_id1264'account_id = $::UUID' : opts.account_id1265'filename = $::TEXT' : opts.filename1266cb : all_results(opts.cb)12671268###1269File editing activity -- users modifying files in any way1270- one single table called file_activity1271- table also records info about whether or not activity has been seen by users1272###1273record_file_use: (opts) =>1274opts = defaults opts,1275project_id : required1276path : required1277account_id : required1278action : required # 'edit', 'read', 'seen', 'chat', etc.?1279cb : required1280# Doing what's done below (with two queries) is really, really ugly.1281# See comment in db-schema.coffee about file_use table -- will redo1282# for postgres later...1283now = new Date()1284entry =1285id : @sha1(opts.project_id, opts.path)1286project_id : opts.project_id1287path : opts.path1288if opts.action == 'edit' or opts.action == 'chat'1289entry.last_edited = now1290async.series([1291(cb) =>1292@_query1293query : 'INSERT INTO file_use'1294conflict : 'id'1295values : entry1296cb : cb1297(cb) =>1298@_query1299query : 'UPDATE file_use'1300jsonb_merge :1301users : {"#{opts.account_id}": {"#{opts.action}": now}}1302where : {id : entry.id}1303cb : cb1304], opts.cb)13051306get_file_use: (opts) =>1307opts = defaults opts,1308max_age_s : undefined1309project_id : undefined # don't specify both project_id and project_ids1310project_ids : undefined1311path : undefined # if given, project_id must be given1312cb : required # one entry if path given; otherwise, an array of entries.1313if opts.project_id?1314if opts.project_ids?1315opts.cb("don't specify both project_id and project_ids")1316return1317else1318opts.project_ids = [opts.project_id]1319else if not opts.project_ids?1320opts.cb("project_id or project_ids must be defined")1321return1322@_query1323query : 'SELECT * FROM file_use'1324where :1325'last_edited >= $::TIMESTAMP' : if opts.max_age_s then misc.seconds_ago(opts.max_age_s)1326'project_id = ANY($)' : opts.project_ids1327'path = $::TEXT' : opts.path1328order_by : 'last_edited'1329cb : if opts.path? then one_result(opts.cb) else all_results(opts.cb)13301331_validate_opts: (opts) =>1332for k, v of opts1333if k == 'lti_id'1334if not (Array.isArray(v) and v.length > 0)1335opts.cb?("invalid #{k} -- can't be an empty array")1336return false1337for x in v1338if not (typeof x == 'string' and x.length > 0)1339opts.cb?("invalid #{k} -- #{v}")1340return false1341else if k.slice(k.length-2) == 'id'1342if v? and not misc.is_valid_uuid_string(v)1343opts.cb?("invalid #{k} -- #{v}")1344return false1345if k.slice(k.length-3) == 'ids'1346for w in v1347if not misc.is_valid_uuid_string(w)1348opts.cb?("invalid uuid #{w} in #{k} -- #{misc.to_json(v)}")1349return false1350if k == 'group' and v not in misc.PROJECT_GROUPS1351opts.cb?("unknown project group '#{v}'"); return false1352if k == 'groups'1353for w in v1354if w not in misc.PROJECT_GROUPS1355opts.cb?("unknown project group '#{w}' in groups"); return false13561357return true13581359get_project: (opts) =>1360opts = defaults opts,1361project_id : required # an array of id's1362columns : PROJECT_COLUMNS1363cb : required1364if not @_validate_opts(opts) then return1365@_query1366query : "SELECT #{opts.columns.join(',')} FROM projects"1367where : 'project_id :: UUID = $' : opts.project_id1368cb : one_result(opts.cb)13691370_get_project_column: (column, project_id, cb) =>1371if not misc.is_valid_uuid_string(project_id)1372cb("invalid project_id -- #{project_id}: getting column #{column}")1373return1374@_query1375query : "SELECT #{column} FROM projects"1376where : 'project_id :: UUID = $' : project_id1377cb : one_result(column, cb)13781379get_user_column: (column, account_id, cb) =>1380if not misc.is_valid_uuid_string(account_id)1381cb("invalid account_id -- #{account_id}: getting column #{column}")1382return1383@_query1384query : "SELECT #{column} FROM accounts"1385where : 'account_id :: UUID = $' : account_id1386cb : one_result(column, cb)13871388add_user_to_project: (opts) =>1389opts = defaults opts,1390project_id : required1391account_id : required1392group : 'collaborator' # see misc.PROJECT_GROUPS above1393cb : required # cb(err)13941395if not @_validate_opts(opts) then return13961397@_query1398query : 'UPDATE projects'1399jsonb_merge :1400users :1401"#{opts.account_id}":1402group: opts.group1403where :1404"project_id = $::UUID": opts.project_id1405cb : opts.cb14061407set_project_status: (opts) =>1408opts = defaults opts,1409project_id : required1410status : required1411cb : undefined1412@_query1413query : "UPDATE projects"1414set : {"status::JSONB" : opts.status}1415where : {"project_id = $::UUID": opts.project_id}1416cb : opts.cb141714181419# Remove the given collaborator from the project.1420# Attempts to remove an *owner* via this function will silently fail (change their group first),1421# as will attempts to remove a user not on the project, or to remove from a non-existent project.1422remove_collaborator_from_project: (opts) =>1423opts = defaults opts,1424project_id : required1425account_id : required1426cb : required # cb(err)1427if not @_validate_opts(opts) then return1428@_query1429query : 'UPDATE projects'1430jsonb_set : {users : {"#{opts.account_id}": null}}1431where :1432'project_id :: UUID = $' : opts.project_id1433"users#>>'{#{opts.account_id},group}' != $::TEXT" : 'owner'1434cb : opts.cb14351436# remove any user, even an owner.1437remove_user_from_project: (opts) =>1438opts = defaults opts,1439project_id : required1440account_id : required1441cb : required # cb(err)1442if not @_validate_opts(opts) then return1443@_query1444query : 'UPDATE projects'1445jsonb_set : {users : {"#{opts.account_id}": null}}1446where : {'project_id :: UUID = $' : opts.project_id}1447cb : opts.cb14481449# Return a list of the account_id's of all collaborators of the given users.1450get_collaborator_ids: (opts) =>1451opts = defaults opts,1452account_id : required1453cb : required1454dbg = @_dbg("get_collaborator_ids")1455@_query1456query : "SELECT DISTINCT jsonb_object_keys(users) FROM projects"1457where : "users ? $::TEXT" : opts.account_id1458cb : all_results('jsonb_object_keys', opts.cb)14591460# get list of project collaborator IDs1461get_collaborators: (opts) =>1462opts = defaults opts,1463project_id : required1464cb : required1465dbg = @_dbg("get_collaborators")1466@_query1467query : "SELECT DISTINCT jsonb_object_keys(users) FROM projects"1468where : "project_id = $::UUID" : opts.project_id1469cb : all_results('jsonb_object_keys', opts.cb)147014711472# return list of paths that are public and not disabled in the given project1473get_public_paths: (opts) =>1474opts = defaults opts,1475project_id : required1476cb : required1477if not @_validate_opts(opts) then return1478@_query1479query : "SELECT path FROM public_paths"1480where : [1481"project_id = $::UUID" : opts.project_id,1482"disabled IS NOT TRUE"1483]1484cb : all_results('path', opts.cb)14851486has_public_path: (opts) =>1487opts = defaults opts,1488project_id : required1489cb : required # cb(err, has_public_path)1490@_query1491query : "SELECT COUNT(path) FROM public_paths"1492where : [1493"project_id = $::UUID" : opts.project_id,1494"disabled IS NOT TRUE"1495]1496cb : count_result (err, n) ->1497opts.cb(err, n>0)14981499path_is_public: (opts) =>1500opts = defaults opts,1501project_id : required1502path : required1503cb : required1504# Get all public paths for the given project_id, then check if path is "in" one according1505# to the definition in misc.1506# TODO: maybe (?) implement caching + changefeeds so that we only do the get once.1507@get_public_paths1508project_id : opts.project_id1509cb : (err, public_paths) =>1510if err1511opts.cb(err)1512else1513opts.cb(undefined, misc.path_is_in_public_paths(opts.path, public_paths))15141515filter_public_paths: (opts) =>1516opts = defaults opts,1517project_id : required1518path : required1519listing : required # files in path [{name:..., isdir:boolean, ....}, ...]1520cb : required1521# Get all public paths for the given project_id, then check if path is "in" one according1522# to the definition in misc.1523@get_public_paths1524project_id : opts.project_id1525cb : (err, public_paths) =>1526if err1527opts.cb(err)1528return1529if misc.path_is_in_public_paths(opts.path, public_paths)1530# nothing to do -- containing path is public1531listing = opts.listing1532else1533listing = misc.deep_copy(opts.listing) # don't mututate input on general principle1534# some files in the listing might not be public, since the containing path isn't public, so we filter1535# WARNING: this is kind of stupid since misc.path_is_in_public_paths is badly implemented, especially1536# for this sort of iteration. TODO: make this faster. This could matter since is done on server.1537try1538# we use try/catch here since there is no telling what is in the listing object; the user1539# could pass in anything...1540listing.files = (x for x in listing.files when \1541misc.path_is_in_public_paths(misc.path_to_file(opts.path, x.name), public_paths))1542catch1543listing.files = []1544opts.cb(undefined, listing)15451546# Set last_edited for this project to right now, and possibly update its size.1547# It is safe and efficient to call this function very frequently since it will1548# actually hit the database at most once every 30s (per project, per client). In particular,1549# once called, it ignores subsequent calls for the same project for 30s.1550touch_project: (opts) =>1551opts = defaults opts,1552project_id : required1553cb : undefined1554if not @_validate_opts(opts) then return1555if @_throttle('touch_project', 30, opts.project_id)1556opts.cb?()1557return1558@_query1559query : "UPDATE projects"1560set : {last_edited : 'NOW()'}1561where : "project_id = $::UUID" : opts.project_id1562cb : opts.cb15631564recently_modified_projects: (opts) =>1565opts = defaults opts,1566max_age_s : required1567cb : required1568@_query1569query : "SELECT project_id FROM projects"1570where : "last_edited >= $::TIMESTAMP" : misc.seconds_ago(opts.max_age_s)1571cb : all_results('project_id', opts.cb)15721573get_open_unused_projects: (opts) =>1574opts = defaults opts,1575min_age_days : 30 # project must not have been edited in this much time1576max_age_days : 120 # project must have been edited at most this long ago1577host : required # hostname of where project is opened1578cb : required1579@_query1580query : "SELECT project_id FROM projects"1581where : [1582"last_edited >= $::TIMESTAMP" : misc.days_ago(opts.max_age_days)1583"last_edited <= $::TIMESTAMP" : misc.days_ago(opts.min_age_days)1584"host#>>'{host}' = $::TEXT " : opts.host,1585"state#>>'{state}' = 'opened'"1586]1587cb : all_results('project_id', opts.cb)15881589# cb(err, true if user is in one of the groups for the project **or an admin**)1590user_is_in_project_group: (opts) =>1591opts = defaults opts,1592project_id : required1593account_id : undefined1594groups : ['owner', 'collaborator']1595cache : false # if true cache result for a few seconds1596cb : required # cb(err, true if in group)1597if not opts.account_id?1598# clearly user -- who isn't even signed in -- is not in the group1599opts.cb(undefined, false)1600return1601if not @_validate_opts(opts) then return1602@_query1603query : 'SELECT COUNT(*) FROM projects'1604cache : opts.cache1605where :1606'project_id :: UUID = $' : opts.project_id1607"users#>>'{#{opts.account_id},group}' = ANY($)" : opts.groups1608cb : count_result (err, n) =>1609if err1610opts.cb(err)1611else if n == 01612# one more chance -- admin?1613@is_admin1614account_id : opts.account_id1615cb : opts.cb1616else1617opts.cb(err, n > 0)16181619# cb(err, true if user is an actual collab; ADMINS do not count)1620user_is_collaborator: (opts) =>1621opts = defaults opts,1622project_id : required1623account_id : required1624cache : true1625cb : required # cb(err, true if is actual collab on project)1626if not @_validate_opts(opts) then return1627@_query1628query : 'SELECT COUNT(*) FROM projects'1629cache : opts.cache1630where : ['project_id :: UUID = $1', "users ? $2"]1631params: [opts.project_id, opts.account_id]1632cb : count_result (err, n) =>1633if err1634opts.cb(err)1635else1636opts.cb(err, n > 0)16371638# all id's of projects having anything to do with the given account1639get_project_ids_with_user: (opts) =>1640opts = defaults opts,1641account_id : required1642is_owner : undefined # if set to true, only return projects with this owner.1643cb : required # opts.cb(err, [project_id, project_id, project_id, ...])1644if not @_validate_opts(opts) then return16451646if opts.is_owner1647where = {"users#>>'{#{opts.account_id},group}' = $::TEXT" : 'owner'}1648else1649where = {'users ? $::TEXT' : opts.account_id}1650@_query1651query : 'SELECT project_id FROM projects'1652where : where1653cb : all_results('project_id', opts.cb)16541655# cb(err, array of account_id's of accounts in non-invited-only groups)1656# TODO: add something about invited users too and show them in UI!1657get_account_ids_using_project: (opts) =>1658opts = defaults opts,1659project_id : required1660cb : required1661if not @_validate_opts(opts) then return1662@_query1663query : 'SELECT users FROM projects'1664where : 'project_id :: UUID = $' : opts.project_id1665cb : one_result 'users', (err, users) =>1666if err1667opts.cb(err)1668return1669opts.cb(undefined, if users? then (id for id,v of users when v.group?.indexOf('invite') == -1) else [])16701671# Have we successfully (no error) sent an invite to the given email address?1672# If so, returns timestamp of when.1673# If not, returns 0.1674when_sent_project_invite: (opts) =>1675opts = defaults opts,1676project_id : required1677to : required # an email address1678cb : required1679if not @_validate_opts(opts) then return1680# in particular, emails like bla'[email protected] → bla''[email protected]1681sani_to = @sanitize("{\"#{opts.to}\"}")1682query_select = "SELECT invite#>#{sani_to} AS to FROM projects"1683@_query1684query : query_select1685where : 'project_id :: UUID = $' : opts.project_id1686cb : one_result 'to', (err, y) =>1687opts.cb(err, if not y? or y.error or not y.time then 0 else new Date(y.time))16881689# call this to record that we have sent an email invite to the given email address1690sent_project_invite: (opts) =>1691opts = defaults opts,1692project_id : required1693to : required # an email address1694error : undefined # if there was an error set it to this; leave undefined to mean that sending succeeded1695cb : undefined1696x = {time: new Date()}1697if opts.error?1698x.error = opts.error1699@_query1700query : "UPDATE projects"1701jsonb_merge :1702{invite : "#{opts.to}" : {time: new Date(), error:opts.error}}1703where : 'project_id :: UUID = $' : opts.project_id1704cb : opts.cb17051706###1707Project host, storage location, and state.1708###1709set_project_host: (opts) =>1710opts = defaults opts,1711project_id : required1712host : required1713cb : required1714assigned = new Date()1715@_query1716query : "UPDATE projects"1717jsonb_set :1718host : {host:opts.host, assigned:assigned}1719where : 'project_id :: UUID = $' : opts.project_id1720cb : (err) => opts.cb(err, assigned)17211722unset_project_host: (opts) =>1723opts = defaults opts,1724project_id : required1725cb : required1726@_query1727query : "UPDATE projects"1728set :1729host : null1730where : 'project_id :: UUID = $' : opts.project_id1731cb : opts.cb17321733get_project_host: (opts) =>1734opts = defaults opts,1735project_id : required1736cb : required1737@_query1738query : "SELECT host#>>'{host}' AS host FROM projects"1739where : 'project_id :: UUID = $' : opts.project_id1740cb : one_result('host', opts.cb)17411742set_project_storage: (opts) =>1743opts = defaults opts,1744project_id : required1745host : required1746cb : required1747@get_project_storage1748project_id : opts.project_id1749cb : (err, current) =>1750if err1751opts.cb(err)1752return1753if current?.host? and current.host != opts.host1754opts.cb("change storage not implemented yet -- need to implement saving previous host")1755else1756# easy case -- assigning for the first time1757assigned = new Date()1758@_query1759query : "UPDATE projects"1760jsonb_set :1761storage : {host:opts.host, assigned:assigned}1762where : 'project_id :: UUID = $' : opts.project_id1763cb : (err) => opts.cb(err, assigned)17641765get_project_storage: (opts) =>1766opts = defaults opts,1767project_id : required1768cb : required1769@_get_project_column('storage', opts.project_id, opts.cb)17701771update_project_storage_save: (opts) =>1772opts = defaults opts,1773project_id : required1774cb : required1775@_query1776query : "UPDATE projects"1777jsonb_merge :1778storage : {saved:new Date()}1779where : 'project_id :: UUID = $' : opts.project_id1780cb : opts.cb17811782set_project_storage_request: (opts) =>1783opts = defaults opts,1784project_id : required1785action : required # 'save', 'close', 'open', 'move'1786target : undefined # needed for 'open' and 'move'1787cb : required1788x =1789action : opts.action1790requested : new Date()1791if opts.target?1792x.target = opts.target1793@_query1794query : "UPDATE projects"1795set :1796"storage_request::JSONB" : x1797where : 'project_id :: UUID = $' : opts.project_id1798cb : opts.cb17991800get_project_storage_request: (opts) =>1801opts = defaults opts,1802project_id : required1803cb : required1804@_get_project_column('storage_request', opts.project_id, opts.cb)18051806set_project_state: (opts) =>1807opts = defaults opts,1808project_id : required1809state : required1810time : new Date()1811error : undefined1812ip : undefined # optional ip address1813cb : required1814if typeof(opts.state) != 'string'1815opts.cb("invalid state type")1816return1817if not COMPUTE_STATES[opts.state]?1818opts.cb("state = '#{opts.state}' it not a valid state")1819return1820state =1821state : opts.state1822time : opts.time1823if opts.error1824state.error = opts.error1825if opts.ip1826state.ip = opts.ip1827@_query1828query : "UPDATE projects"1829set : "state::JSONB" : state1830where : 'project_id :: UUID = $' : opts.project_id1831cb : opts.cb18321833get_project_state: (opts) =>1834opts = defaults opts,1835project_id : required1836cb : required1837@_get_project_column('state', opts.project_id, opts.cb)18381839###1840Project quotas and upgrades1841###18421843# Returns the total quotas for the project, including any1844# upgrades to the base settings.1845get_project_quotas: (opts) =>1846opts = defaults opts,1847project_id : required1848cb : required1849settings = users = site_license = server_settings = undefined1850async.parallel([1851(cb) =>1852@_query1853query : 'SELECT settings, users, site_license FROM projects'1854where : 'project_id = $::UUID' : opts.project_id1855cb : one_result (err, x) =>1856settings = x.settings1857site_license = x.site_license1858users = x.users1859cb(err)1860(cb) =>1861@get_server_settings_cached1862cb : (err, x) =>1863server_settings = x1864cb(err)1865], (err) =>1866if err1867opts.cb(err)1868else1869upgrades = quota(settings, users, site_license, server_settings)1870opts.cb(undefined, upgrades)1871)18721873# Return mapping from project_id to map listing the upgrades this particular user1874# applied to the given project. This only includes project_id's of projects that1875# this user may have upgraded in some way.1876get_user_project_upgrades: (opts) =>1877opts = defaults opts,1878account_id : required1879cb : required1880@_query1881query : "SELECT project_id, users#>'{#{opts.account_id},upgrades}' AS upgrades FROM projects"1882where : [1883'users ? $::TEXT' : opts.account_id, # this is a user of the project1884"users#>'{#{opts.account_id},upgrades}' IS NOT NULL" # upgrades are defined1885]1886cb : (err, result) =>1887if err1888opts.cb(err)1889else1890x = {}1891for p in result.rows1892x[p.project_id] = p.upgrades1893opts.cb(undefined, x)18941895# Ensure that all upgrades applied by the given user to projects are consistent,1896# truncating any that exceed their allotment. NOTE: Unless there is a bug,1897# the only way the quotas should ever exceed their allotment would be if the1898# user is trying to cheat... *OR* a subscription was canceled or ended.1899ensure_user_project_upgrades_are_valid: (opts) =>1900opts = defaults opts,1901account_id : required1902fix : true # if true, will fix projects in database whose quotas exceed the allotted amount; it is the caller's responsibility to actually change them.1903cb : required # cb(err, excess)1904dbg = @_dbg("ensure_user_project_upgrades_are_valid(account_id='#{opts.account_id}')")1905dbg()1906excess = stripe_data = project_upgrades = undefined1907async.series([1908(cb) =>1909async.parallel([1910(cb) =>1911@_query1912query : 'SELECT stripe_customer FROM accounts'1913where : 'account_id = $::UUID' : opts.account_id1914cb : one_result 'stripe_customer', (err, stripe_customer) =>1915stripe_data = stripe_customer?.subscriptions?.data1916cb(err)1917(cb) =>1918@get_user_project_upgrades1919account_id : opts.account_id1920cb : (err, x) =>1921project_upgrades = x1922cb(err)1923], cb)1924(cb) =>1925excess = require('@cocalc/util/upgrades').available_upgrades(stripe_data, project_upgrades).excess1926if opts.fix1927fix = (project_id, cb) =>1928dbg("fixing project_id='#{project_id}' with excess #{JSON.stringify(excess[project_id])}")1929upgrades = undefined1930async.series([1931(cb) =>1932@_query1933query : "SELECT users#>'{#{opts.account_id},upgrades}' AS upgrades FROM projects"1934where : 'project_id = $::UUID' : project_id1935cb : one_result 'upgrades', (err, x) =>1936upgrades = x; cb(err)1937(cb) =>1938if not upgrades?1939cb(); return1940# WORRY: this is dangerous since if something else changed about a user1941# between the read/write here, then we would have trouble. (This is milliseconds of time though...)1942for k, v of excess[project_id]1943upgrades[k] -= v1944@_query1945query : "UPDATE projects"1946where : 'project_id = $::UUID' : project_id1947jsonb_merge :1948users : {"#{opts.account_id}": {upgrades: upgrades}}1949cb : cb1950], cb)1951async.map(misc.keys(excess), fix, cb)1952else1953cb()1954], (err) =>1955opts.cb(err, excess)1956)19571958# Loop through every user of cocalc that is connected with stripe (so may have a subscription),1959# and ensure that any upgrades that have applied to projects are valid. It is important to1960# run this periodically or there is a really natural common case where users can cheat:1961# (1) they apply upgrades to a project1962# (2) their subscription expires1963# (3) they do NOT touch upgrades on any projects again.1964ensure_all_user_project_upgrades_are_valid: (opts) =>1965opts = defaults opts,1966limit : 1 # We only default to 1 at a time, since there is no hurry.1967cb : required1968dbg = @_dbg("ensure_all_user_project_upgrades_are_valid")1969locals = {}1970async.series([1971(cb) =>1972@_query1973query : "SELECT account_id FROM accounts"1974where : "stripe_customer_id IS NOT NULL"1975timeout_s: 3001976cb : all_results 'account_id', (err, account_ids) =>1977locals.account_ids = account_ids1978cb(err)1979(cb) =>1980m = 01981n = locals.account_ids.length1982dbg("got #{n} accounts with stripe")1983f = (account_id, cb) =>1984m += 11985dbg("#{m}/#{n}")1986@ensure_user_project_upgrades_are_valid1987account_id : account_id1988cb : cb1989async.mapLimit(locals.account_ids, opts.limit, f, cb)1990], opts.cb)19911992# Return the sum total of all user upgrades to a particular project1993get_project_upgrades: (opts) =>1994opts = defaults opts,1995project_id : required1996cb : required1997@_query1998query : 'SELECT users FROM projects'1999where : 'project_id = $::UUID' : opts.project_id2000cb : one_result 'users', (err, users) =>2001if err2002opts.cb(err); return2003upgrades = undefined2004if users?2005for account_id, info of users2006upgrades = misc.map_sum(upgrades, info.upgrades)2007opts.cb(undefined, upgrades)20082009# Remove all upgrades to all projects applied by this particular user.2010remove_all_user_project_upgrades: (opts) =>2011opts = defaults opts,2012account_id : required2013projects : undefined # if given, only remove from projects with id in this array.2014cb : required2015if not misc.is_valid_uuid_string(opts.account_id)2016opts.cb("invalid account_id")2017return2018query = "UPDATE projects SET users=jsonb_set(users, '{#{opts.account_id}}', jsonb(users#>'{#{opts.account_id}}') - 'upgrades')"2019where = [2020'users ? $::TEXT' : opts.account_id, # this is a user of the project2021"users#>'{#{opts.account_id},upgrades}' IS NOT NULL" # upgrades are defined2022]2023if opts.projects2024if not misc.is_array(opts.projects)2025opts.cb("projects must be an array")2026return2027w = []2028for project_id in opts.projects2029if not misc.is_valid_uuid_string(project_id)2030opts.cb('each entry in projects must be a valid uuid')2031return2032w.push("'#{project_id}'")2033where.push("project_id in (#{w.join(',')})")20342035@_query2036query : query2037where : where2038cb: opts.cb2039# TODO: any impacted project that is currently running should also (optionally?) get restarted.2040# I'm not going to bother for now, but this DOES need to get implemented, since otherwise users2041# can cheat too easily. Alternatively, have a periodic control loop on all running projects that2042# confirms that everything is legit (and remove the verification code for user_query) --2043# that's probably better. This could be a service called manage-upgrades.20442045###2046Project settings2047###2048get_project_settings: (opts) =>2049opts = defaults opts,2050project_id : required2051cb : required2052@_query2053query : "SELECT settings FROM projects"2054where : 'project_id = $::UUID' : opts.project_id2055cb : one_result 'settings', (err, settings) =>2056if err2057opts.cb(err)2058else if not settings?2059opts.cb(undefined, misc.copy(DEFAULT_QUOTAS))2060else2061settings = misc.coerce_codomain_to_numbers(settings)2062quotas = {}2063for k, v of DEFAULT_QUOTAS2064quotas[k] = if not settings[k]? then v else settings[k]2065opts.cb(undefined, quotas)20662067set_project_settings: (opts) =>2068opts = defaults opts,2069project_id : required2070settings : required # can be any subset of the map2071cb : required2072@_query2073query : "UPDATE projects"2074where : 'project_id = $::UUID' : opts.project_id2075jsonb_merge : {settings: opts.settings}2076cb : opts.cb20772078get_project_extra_env: (opts) =>2079opts = defaults opts,2080project_id : required2081cb : required2082@_query2083query : "SELECT env FROM projects"2084where : 'project_id = $::UUID' : opts.project_id2085cb : one_result 'env', (err, env) =>2086if err2087opts.cb(err)2088else2089opts.cb(undefined, env ? {})209020912092recent_projects: (opts) =>2093opts = defaults opts,2094age_m : required # return results at most this old2095min_age_m : 0 # only returns results at least this old2096pluck : undefined # if not given, returns list of project_id's; if given (as an array), returns objects with these fields2097cb : required # cb(err, list of strings or objects)20982099if opts.pluck?2100columns = opts.pluck.join(',')2101cb = all_results(opts.cb)2102else2103columns = 'project_id'2104cb = all_results('project_id', opts.cb)2105@_query2106query : "SELECT #{columns} FROM projects"2107where :2108"last_edited >= $::TIMESTAMP" : misc.minutes_ago(opts.age_m)2109"last_edited <= $::TIMESTAMP" : misc.minutes_ago(opts.min_age_m)2110cb : cb21112112get_stats_interval: (opts) =>2113opts = defaults opts,2114start : required2115end : required2116cb : required2117@_query2118query : 'SELECT * FROM stats'2119where :2120"time >= $::TIMESTAMP" : opts.start2121"time <= $::TIMESTAMP" : opts.end2122order_by : 'time'2123cb : all_results(opts.cb)21242125# If there is a cached version of stats (which has given ttl) return that -- this could have2126# been computed by any of the hubs. If there is no cached version, compute new one and store2127# in cache for ttl seconds.2128get_stats: (opts) =>2129opts = defaults opts,2130ttl_dt : 15 # 15 secs subtracted from ttl to compensate for computation duration when called via a cronjob2131ttl : 5*60 # how long cached version lives (in seconds)2132ttl_db : 30 # how long a valid result from a db query is cached in any case2133update : true # true: recalculate if older than ttl; false: don't recalculate and pick it from the DB (locally cached for ttl secs)2134cb : undefined2135return await calc_stats(@, opts)21362137get_active_student_stats: (opts) =>2138opts = defaults opts,2139cb : required2140dbg = @_dbg('get_active_student_stats')2141dbg()2142@_query2143query : "SELECT project_id, course, last_edited, settings, users FROM projects WHERE course IS NOT NULL AND last_edited >= $1"2144params : [misc.days_ago(30)]2145cb : all_results (err, t) =>2146if err2147opts.cb(err)2148return2149days14 = misc.days_ago(14)2150days7 = misc.days_ago(7)2151days1 = misc.days_ago(1)2152# student pay means that the student is required to pay2153num_student_pay = (x for x in t when x.course.pay).length2154# prof pay means that student isn't required to pay but2155# nonetheless project is on members only host2156num_prof_pay = 02157for x in t2158if not x.course.pay # student isn't paying2159if x.settings?.member_host2160num_prof_pay += 12161continue2162for _, d of x.users2163if d.upgrades?.member_host2164num_prof_pay += 12165continue2166# free - neither student pays, and also project not on members only server2167num_free = t.length - num_prof_pay - num_student_pay2168conversion_rate = if t.length then 100*(num_student_pay + num_prof_pay) / t.length else 02169data =2170conversion_rate : conversion_rate2171num_student_pay : num_student_pay2172num_prof_pay : num_prof_pay2173num_free : num_free2174num_1days : (x for x in t when x.last_edited >= days1).length2175num_7days : (x for x in t when x.last_edited >= days7).length2176num_14days : (x for x in t when x.last_edited >= days14).length2177num_30days : t.length2178opts.cb(undefined, data)217921802181###2182Hub servers2183###2184register_hub: (opts) =>2185opts = defaults opts,2186host : required2187port : required2188clients : required2189ttl : required2190cb : required2191# Since multiple hubs can run on the same host (but with different ports) and the host is the primary2192# key, we combine the host and port number in the host name for the db. The hub_servers table is only2193# used for tracking connection stats, so this is safe.2194@_query2195query : "INSERT INTO hub_servers"2196values :2197"host :: TEXT " : "#{opts.host}-#{opts.port}"2198"port :: INTEGER " : opts.port2199"clients :: INTEGER " : opts.clients2200"expire :: TIMESTAMP" : expire_time(opts.ttl)2201conflict : 'host'2202cb : opts.cb22032204get_hub_servers: (opts) =>2205opts = defaults opts,2206cb : required2207@_query2208query : "SELECT * FROM hub_servers"2209cb : all_results (err, v) =>2210if err2211opts.cb(err)2212return2213w = []2214to_delete = []2215now = new Date()2216for x in v2217if x.expire and x.expire <= now2218to_delete.push(x.host)2219else2220w.push(x)2221if to_delete.length > 02222@_query2223query : "DELETE FROM hub_servers"2224where : "host = ANY($)" : to_delete2225cb : (err) => opts.cb(err, w)2226else2227opts.cb(undefined, w)22282229###2230Custom software images2231###22322233# this is 100% for cc-in-cc dev projects only!2234insert_random_compute_images: (opts) =>2235opts = defaults opts,2236cb : required22372238dbg = @_dbg("database::insert_random_compute_images")2239dbg()22402241capitalize = require('@cocalc/util/misc').capitalize22422243words = [2244'wizard', 'jupyter', 'carrot', 'python', 'science', 'gold', 'eagle',2245'advanced', 'course', 'yellow', 'bioinformatics', 'R', 'electric', 'sheep',2246'theory', 'math', 'physics', 'calculate', 'primer', 'DNA', 'tech', 'space'2247]22482249# deterministically sample distinct words (such that this is stable after a restart)2250sample = (idx=0, n=1) ->2251N = words.length2252K = (idx * 997) %% N2253ret = []2254for i in [0..n]2255for j in [0..N]2256w = words[(K + 97 * i + j) %% N]2257if ret.includes(w)2258continue2259else2260ret.push(w)2261break2262return ret22632264rseed = 1232265random = ->2266x = Math.sin(rseed++)2267r = x - Math.floor(x)2268return r22692270create = (idx, cb) =>2271rnd = sample(idx, 3)2272id = rnd[...2].join('-') + "-#{idx}"2273provider = ['github.com', 'gitlab.com', 'bitbucket.org'][idx % 3]2274src = "https://#{provider}/#{rnd[2]}/#{id}.git"22752276# not all of them have a display-title, url, desc, ...2277if random() > .252278if random() > .52279extra = "(#{sample(idx + 2)})"2280else2281extra = sample(idx+5, 2)2282disp = (capitalize(_) for _ in rnd[...2].concat(extra)).join(' ')2283else2284if random() > .52285disp = undefined2286else2287disp = ''22882289if random() > .52290url = "https://www.google.com/search?q=#{rnd.join('%20')}"2291else2292url = undefined22932294if random() > .52295if random() > .52296verylong = Array(100).fill('very long *text* for **testing**, ').join(" ")2297if url?2298other_page = ", or point to [yet another page](#{url})"2299else2300other_page = ""2301desc = """2302This is some text describing what **#{disp or id}** is.2303Here could also be an [external link](https://doc.cocalc.com).2304It might also mention `#{id}`#{other_page}.23052306#{verylong ? ''}2307"""2308else2309desc = undefined23102311path = if random() > .5 then "index.ipynb" else "subdir/"2312tag = if random() > .25 then "master" else null231323142315@_query2316query : "INSERT INTO compute_images"2317values :2318"id :: TEXT " : id2319"src :: TEXT " : src2320"type :: TEXT " : 'custom'2321"desc :: TEXT " : desc2322"display :: TEXT " : disp2323"path :: TEXT " : path2324"url :: TEXT " : url2325"disabled:: BOOLEAN " : idx == 12326cb : cb23272328# first we wipe the table's content, then we generate some random stuff2329async.series([2330(cb) =>2331@_query2332query : 'DELETE FROM compute_images'2333where : '1 = 1'2334cb : cb23352336(cb) =>2337async.mapSeries([0..20], create, cb)23382339], (err) =>2340dbg("all done")2341opts.cb()2342)2343234423452346# Delete all patches, the blobs if archived, and the syncstring object itself2347# Basically this erases everything from cocalc related to the file edit history2348# of a given file... except ZFS snapshots.2349delete_syncstring: (opts) =>2350opts = defaults opts,2351string_id : required2352cb : required2353if not opts.string_id or misc.len(opts.string_id) != 402354# be extra careful!2355opts.cb("invalid string_id")2356return23572358locals =2359syncstring : undefined2360where : {"string_id = $::CHAR(40)" : opts.string_id}23612362async.series([2363(cb) =>2364@_query2365query : "SELECT * FROM syncstrings"2366where : locals.where2367cb : (err, results) =>2368if err2369cb(err)2370return2371locals.syncstring = results.rows[0]2372cb()2373(cb) =>2374if not locals.syncstring?2375# no syncstring with this id.2376cb(); return2377# delete the syncstring record (we do this first before deleting what if references,2378# since having a syncstring record referencing missing data would be a disaster, meaning2379# the user could never open their file -- with this sequence it just means some wasted2380# disks pace).2381@_query2382query : "DELETE FROM syncstrings"2383where : locals.where2384cb : cb2385(cb) =>2386if not locals.syncstring?2387# no syncstring with this id.2388cb(); return2389if locals.syncstring.archived2390# is archived, so delete the blob2391@delete_blob2392uuid : locals.syncstring.archived2393cb : cb2394else2395# is not archived, so delete the patches2396@_query2397query : "DELETE FROM patches"2398where : locals.where2399timeout_s: 3002400cb : cb2401], opts.cb)24022403# async function2404site_license_usage_stats: () =>2405return await site_license_usage_stats(@)24062407# async function2408projects_using_site_license: (opts) =>2409return await projects_using_site_license(@, opts)24102411# async function2412number_of_projects_using_site_license: (opts) =>2413return await number_of_projects_using_site_license(@, opts)24142415# async function2416site_license_public_info: (license_id) =>2417return await site_license_public_info(@, license_id)24182419# async function2420site_license_manager_set: (license_id, info) =>2421return await site_license_manager_set(@, license_id, info)24222423# async function2424update_site_license_usage_log: =>2425return await update_site_license_usage_log(@)24262427# async function2428matching_site_licenses: (...args) =>2429return await matching_site_licenses(@, ...args)24302431# async function2432manager_site_licenses: (...args) =>2433return await manager_site_licenses(@, ...args)24342435# async function2436project_datastore_set: (...args) =>2437return await project_datastore_set(@, ...args)24382439# async function2440project_datastore_get: (...args) =>2441return await project_datastore_get(@, ...args)24422443# async function2444project_datastore_del: (...args) =>2445return await project_datastore_del(@, ...args)24462447# async function2448permanently_unlink_all_deleted_projects_of_user: (account_id_or_email_address) =>2449return await permanently_unlink_all_deleted_projects_of_user(@, account_id_or_email_address)24502451# async function2452unlink_old_deleted_projects: () =>2453return await unlink_old_deleted_projects(@)24542455# async function2456unlist_all_public_paths: (account_id, is_owner) =>2457return await unlist_all_public_paths(@, account_id, is_owner)24582459# async2460projects_that_need_to_be_started: () =>2461return await projects_that_need_to_be_started(@)24622463# async2464# this *merges* in the run_quota; it doesn't replace it.2465set_run_quota: (project_id, run_quota) =>2466return await @async_query2467query : "UPDATE projects"2468jsonb_merge : {run_quota:run_quota}2469where : {project_id:project_id}24702471# async -- true if they are a manager on a license or have2472# any subscriptions.2473is_paying_customer: (account_id) =>2474return await is_paying_customer(@, account_id)24752476# async2477get_all_public_paths: (account_id) =>2478return await get_all_public_paths(@, account_id)24792480# async2481# Return true if the given account is a member or2482# owner of the given organization.2483accountIsInOrganization: (opts) =>2484result = await @async_query2485query : 'SELECT COUNT(*) FROM organizations'2486cache : true2487where : ['organization_id :: UUID = $1', "users ? $2"]2488params: [opts.organization_id, opts.account_id]2489return parseInt(result?.rows?[0]?.count) > 024902491# given a name, returns undefined if it is not in use,2492# and the account_id or organization_id that is using it2493# if it is in use.2494nameToAccountOrOrganization: (name) =>2495name = name.toLowerCase()2496result = await @async_query2497query : 'SELECT account_id FROM accounts'2498cache : false2499where : ['LOWER(name) = $1']2500params: [name]2501if result.rows.length > 02502return result.rows[0].account_id2503result = await @async_query2504query : 'SELECT organization_id FROM organizations'2505cache : false2506where : ['LOWER(name) = $1']2507params: [name]2508if result.rows.length > 02509return result.rows[0].organization_id2510return undefined25112512# async2513registrationTokens: (options, query) =>2514return await registrationTokens(@, options, query)25152516updateUnreadMessageCount: (opts) =>2517return await updateUnreadMessageCount(opts)251825192520