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/database/postgres-server-queries.coffee
Views: 687
#########################################################################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{syncdoc_history} = require('./postgres/syncdoc-history')43# TODO is set_account_info_if_possible used here?!44{is_paying_customer, set_account_info_if_possible} = require('./postgres/account-queries')45{getStripeCustomerId, syncCustomer} = require('./postgres/stripe')4647{site_license_usage_stats, projects_using_site_license, number_of_projects_using_site_license} = require('./postgres/site-license/analytics')48{update_site_license_usage_log} = require('./postgres/site-license/usage-log')49{site_license_public_info} = require('./postgres/site-license/public')50{site_license_manager_set} = require('./postgres/site-license/manager')51{matching_site_licenses, manager_site_licenses} = require('./postgres/site-license/search')52{project_datastore_set, project_datastore_get, project_datastore_del} = require('./postgres/project-queries')53{permanently_unlink_all_deleted_projects_of_user, unlink_old_deleted_projects} = require('./postgres/delete-projects')54{get_all_public_paths, unlist_all_public_paths} = require('./postgres/public-paths')55{get_personal_user} = require('./postgres/personal')56{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')57{projects_that_need_to_be_started} = require('./postgres/always-running');58{calc_stats} = require('./postgres/stats')59{getServerSettings, resetServerSettingsCache, getPassportsCached, setPassportsCached} = require('@cocalc/database/settings/server-settings');60{pii_expire} = require("./postgres/pii")61passwordHash = require("@cocalc/backend/auth/password-hash").default;62registrationTokens = require('./postgres/registration-tokens').default;6364stripe_name = require('@cocalc/util/stripe/name').default;6566# log events, which contain personal information (email, account_id, ...)67PII_EVENTS = ['create_account',68'change_password',69'change_email_address',70'webapp-add_passport',71'get_user_auth_token',72'successful_sign_in',73'webapp-email_sign_up',74'create_account_registration_token'75]7677exports.extend_PostgreSQL = (ext) -> class PostgreSQL extends ext78# write an event to the central_log table79log: (opts) =>80opts = defaults opts,81event : required # string82value : required # object83cb : undefined8485# always expire central_log entries after 1 year, unless …86expire = expire_time(365*24*60*60)87# exception events expire earlier88if opts.event == 'uncaught_exception'89expire = misc.expire_time(30 * 24 * 60 * 60) # del in 30 days90else91# and user-related events according to the PII time, although "never" falls back to 1 year92v = opts.value93if v.ip_address? or v.email_address? or opts.event in PII_EVENTS94expire = await pii_expire(@) ? expire9596@_query97query : 'INSERT INTO central_log'98values :99'id::UUID' : misc.uuid()100'event::TEXT' : opts.event101'value::JSONB' : opts.value102'time::TIMESTAMP' : 'NOW()'103'expire::TIMESTAMP' : expire104cb : (err) => opts.cb?(err)105106uncaught_exception: (err) =>107# call when things go to hell in some unexpected way; at least108# we attempt to record this in the database...109try110@log111event : 'uncaught_exception'112value : {error:"#{err}", stack:"#{err.stack}", host:require('os').hostname()}113catch e114# IT IS CRITICAL THAT uncaught_exception not raise an exception, since if it115# did then we would hit a horrible infinite loop!116117# dump a range of data from the central_log table118get_log: (opts) =>119opts = defaults opts,120start : undefined # if not given start at beginning of time121end : undefined # if not given include everything until now122log : 'central_log' # which table to query123event : undefined124where : undefined # if given, restrict to records with the given json125# containment, e.g., {account_id:'...'}, only returns126# entries whose value has the given account_id.127cb : required128@_query129query : "SELECT * FROM #{opts.log}"130where :131'time >= $::TIMESTAMP' : opts.start132'time <= $::TIMESTAMP' : opts.end133'event = $::TEXT' : opts.event134'value @> $::JSONB' : opts.where135cb : all_results(opts.cb)136137# Return every entry x in central_log in the given period of time for138# which x.event==event and x.value.account_id == account_id.139get_user_log: (opts) =>140opts = defaults opts,141start : undefined142end : undefined # if not given include everything until now143event : 'successful_sign_in'144account_id : required145cb : required146@get_log147start : opts.start148end : opts.end149event : opts.event150where : {account_id: opts.account_id}151cb : opts.cb152153log_client_error: (opts) =>154opts = defaults opts,155event : 'event'156error : 'error'157account_id : undefined158cb : undefined159# get rid of the entry in 30 days160expire = misc.expire_time(30 * 24 * 60 * 60)161@_query162query : 'INSERT INTO client_error_log'163values :164'id :: UUID' : misc.uuid()165'event :: TEXT' : opts.event166'error :: TEXT' : opts.error167'account_id :: UUID' : opts.account_id168'time :: TIMESTAMP' : 'NOW()'169'expire :: TIMESTAMP' : expire170cb : opts.cb171172webapp_error: (opts) =>173opts = defaults opts,174account_id : undefined175name : undefined176message : undefined177comment : undefined178stacktrace : undefined179file : undefined180path : undefined181lineNumber : undefined182columnNumber : undefined183severity : undefined184browser : undefined185mobile : undefined186responsive : undefined187user_agent : undefined188smc_version : undefined189build_date : undefined190smc_git_rev : undefined191uptime : undefined192start_time : undefined193id : undefined # ignored194cb : undefined195# get rid of the entry in 30 days196expire = misc.expire_time(30 * 24 * 60 * 60)197@_query198query : 'INSERT INTO webapp_errors'199values :200'id :: UUID' : misc.uuid()201'account_id :: UUID' : opts.account_id202'name :: TEXT' : opts.name203'message :: TEXT' : opts.message204'comment :: TEXT' : opts.comment205'stacktrace :: TEXT' : opts.stacktrace206'file :: TEXT' : opts.file207'path :: TEXT' : opts.path208'lineNumber :: INTEGER' : opts.lineNumber209'columnNumber :: INTEGER' : opts.columnNumber210'severity :: TEXT' : opts.severity211'browser :: TEXT' : opts.browser212'mobile :: BOOLEAN' : opts.mobile213'responsive :: BOOLEAN' : opts.responsive214'user_agent :: TEXT' : opts.user_agent215'smc_version :: TEXT' : opts.smc_version216'build_date :: TEXT' : opts.build_date217'smc_git_rev :: TEXT' : opts.smc_git_rev218'uptime :: TEXT' : opts.uptime219'start_time :: TIMESTAMP' : opts.start_time220'time :: TIMESTAMP' : 'NOW()'221'expire :: TIMESTAMP' : expire222cb : opts.cb223224get_client_error_log: (opts) =>225opts = defaults opts,226start : undefined # if not given start at beginning of time227end : undefined # if not given include everything until now228event : undefined229cb : required230opts.log = 'client_error_log'231@get_log(opts)232233set_server_setting: (opts) =>234opts = defaults opts,235name : required236value : required237readonly : undefined # boolean. if yes, that value is not controlled via any UI238cb : required239async.series([240(cb) =>241values =242'name::TEXT' : opts.name243'value::TEXT' : opts.value244if opts.readonly?245values.readonly = !!opts.readonly246@_query247query : 'INSERT INTO server_settings'248values : values249conflict : 'name'250cb : cb251# also set a timestamp252(cb) =>253@_query254query : 'INSERT INTO server_settings'255values :256'name::TEXT' : '_last_update'257'value::TEXT' : (new Date()).toISOString()258conflict : 'name'259cb : cb260], (err) =>261# clear the cache no matter what (e.g., server_settings might have partly changed then errored)262@reset_server_settings_cache()263opts.cb(err)264)265266reset_server_settings_cache: =>267resetServerSettingsCache()268269get_server_setting: (opts) =>270opts = defaults opts,271name : required272cb : required273@_query274query : 'SELECT value FROM server_settings'275where :276"name = $::TEXT" : opts.name277cb : one_result('value', opts.cb)278279get_server_settings_cached: (opts) =>280opts = defaults opts,281cb: required282try283opts.cb(undefined, await getServerSettings())284catch err285opts.cb(err)286287get_site_settings: (opts) =>288opts = defaults opts,289cb : required # (err, settings)290@_query291query : 'SELECT name, value FROM server_settings'292cache : true293where :294"name = ANY($)" : misc.keys(site_settings_conf)295cb : (err, result) =>296if err297opts.cb(err)298else299x = {}300for k in result.rows301if k.name == 'commercial' and k.value in ['true', 'false'] # backward compatibility302k.value = eval(k.value)303x[k.name] = k.value304opts.cb(undefined, x)305306server_settings_synctable: (opts={}) =>307opts.table = 'server_settings'308return @synctable(opts)309310set_passport_settings: (opts) =>311opts = defaults opts,312strategy : required313conf : required314info : undefined315cb : required316return await set_passport_settings(@, opts)317318get_passport_settings: (opts) =>319opts = defaults opts,320strategy : required321return await get_passport_settings(@, opts)322323get_all_passport_settings: () =>324return await get_all_passport_settings(@)325326get_all_passport_settings_cached: () =>327return await get_all_passport_settings_cached(@)328329create_passport: (opts) =>330return await create_passport(@, opts)331332passport_exists: (opts) =>333return await passport_exists(@, opts)334335update_account_and_passport: (opts) =>336return await update_account_and_passport(@, opts)337338###339Creating an account using SSO only.340This needs to be rewritten in @cocalc/server like341all the other account creation. This is horrible342because343###344create_sso_account: (opts={}) =>345opts = defaults opts,346first_name : undefined347last_name : undefined348349created_by : undefined # ip address of computer creating this account350351email_address : undefined352password_hash : undefined353lti_id : undefined # 2-tuple <string[]>[iss, user_id]354355passport_strategy : undefined356passport_id : undefined357passport_profile : undefined358usage_intent : undefined359cb : required # cb(err, account_id)360361dbg = @_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}")362dbg()363364for name in ['first_name', 'last_name']365if opts[name]366test = misc2_node.is_valid_username(opts[name])367if test?368opts.cb("#{name} not valid: #{test}")369return370371if opts.email_address # canonicalize the email address, if given372opts.email_address = misc.lower_email_address(opts.email_address)373374account_id = misc.uuid()375376passport_key = undefined377if opts.passport_strategy?378# This is to make it impossible to accidentally create two accounts with the same passport379# due to calling create_account twice at once. See TODO below about changing schema.380# This should be enough for now since a given user only makes their account through a single381# server via the persistent websocket...382@_create_account_passport_keys ?= {}383passport_key = _passport_key(strategy:opts.passport_strategy, id:opts.passport_id)384last = @_create_account_passport_keys[passport_key]385if last? and new Date() - last <= 60*1000386opts.cb("recent attempt to make account with this passport strategy")387return388@_create_account_passport_keys[passport_key] = new Date()389390async.series([391(cb) =>392if not opts.passport_strategy?393cb(); return394dbg("verify that no account with passport (strategy='#{opts.passport_strategy}', id='#{opts.passport_id}') already exists")395# **TODO:** need to make it so insertion into the table still would yield an error due to396# unique constraint; this will require probably moving the passports397# object to a separate table. This is important, since this is exactly the place where398# a race condition might cause trouble!399@passport_exists400strategy : opts.passport_strategy401id : opts.passport_id402cb : (err, account_id) ->403if err404cb(err)405else if account_id406cb("account with email passport strategy '#{opts.passport_strategy}' and id '#{opts.passport_id}' already exists")407else408cb()409(cb) =>410dbg("create the actual account")411@_query412query : "INSERT INTO accounts"413values :414'account_id :: UUID' : account_id415'first_name :: TEXT' : opts.first_name416'last_name :: TEXT' : opts.last_name417'lti_id :: TEXT[]' : opts.lti_id418'created :: TIMESTAMP' : new Date()419'created_by :: INET' : opts.created_by420'password_hash :: CHAR(173)' : opts.password_hash421'email_address :: TEXT' : opts.email_address422'sign_up_usage_intent :: TEXT': opts.usage_intent423cb : cb424(cb) =>425if opts.passport_strategy?426dbg("add passport authentication strategy")427@create_passport428account_id : account_id429strategy : opts.passport_strategy430id : opts.passport_id431profile : opts.passport_profile432cb : cb433else434cb()435], (err) =>436if err437dbg("error creating account -- #{err}")438opts.cb(err)439else440dbg("successfully created account")441opts.cb(undefined, account_id)442)443444is_admin: (opts) =>445opts = defaults opts,446account_id : required447cb : required448@_query449query : "SELECT groups FROM accounts"450where : 'account_id = $::UUID':opts.account_id451cache : true452cb : one_result 'groups', (err, groups) =>453opts.cb(err, groups? and 'admin' in groups)454455user_is_in_group: (opts) =>456opts = defaults opts,457account_id : required458group : required459cb : required460@_query461query : "SELECT groups FROM accounts"462where : 'account_id = $::UUID':opts.account_id463cache : true464cb : one_result 'groups', (err, groups) =>465opts.cb(err, groups? and opts.group in groups)466467make_user_admin: (opts) =>468opts = defaults opts,469account_id : undefined470email_address : undefined471cb : required472if not opts.account_id? and not opts.email_address?473opts.cb?("account_id or email_address must be given")474return475async.series([476(cb) =>477if opts.account_id?478cb()479else480@get_account481email_address : opts.email_address482columns : ['account_id']483cb : (err, x) =>484if err485cb(err)486else if not x?487cb("no such email address")488else489opts.account_id = x.account_id490cb()491(cb) =>492@clear_cache() # caching is mostly for permissions so this is exactly when it would be nice to clear it.493@_query494query : "UPDATE accounts"495where : 'account_id = $::UUID':opts.account_id496set :497groups : ['admin']498cb : cb499], opts.cb)500501count_accounts_created_by: (opts) =>502opts = defaults opts,503ip_address : required504age_s : required505cb : required506@_count507table : 'accounts'508where :509"created_by = $::INET" : opts.ip_address510"created >= $::TIMESTAMP" : misc.seconds_ago(opts.age_s)511cb : opts.cb512513# Completely delete the given account from the database. This doesn't514# do any sort of cleanup of things associated with the account! There515# is no reason to ever use this, except for testing purposes.516delete_account: (opts) =>517opts = defaults opts,518account_id : required519cb : required520if not @_validate_opts(opts) then return521@_query522query : "DELETE FROM accounts"523where : "account_id = $::UUID" : opts.account_id524cb : opts.cb525526# Mark the account as deleted, thus freeing up the email527# address for use by another account, etc. The actual528# account entry remains in the database, since it may be529# referred to by many other things (projects, logs, etc.).530# However, the deleted field is set to true, so the account531# is excluded from user search.532# TODO: rewritten in packages/server/accounts/delete.ts533mark_account_deleted: (opts) =>534opts = defaults opts,535account_id : undefined536email_address : undefined537cb : required538if not opts.account_id? and not opts.email_address?539opts.cb("one of email address or account_id must be specified -- make sure you are signed in")540return541542query = undefined543email_address = undefined544async.series([545(cb) =>546if opts.account_id?547cb()548else549@account_exists550email_address : opts.email_address551cb : (err, account_id) =>552if err553cb(err)554else if not account_id555cb("no such email address known")556else557opts.account_id = account_id558cb()559(cb) =>560@_query561query : "SELECT email_address FROM accounts"562where : "account_id = $::UUID" : opts.account_id563cb : one_result 'email_address', (err, x) =>564email_address = x; cb(err)565(cb) =>566@_query567query : "UPDATE accounts"568set :569"deleted::BOOLEAN" : true570"email_address_before_delete::TEXT" : email_address571"email_address" : null572"passports" : null573where : "account_id = $::UUID" : opts.account_id574cb : cb575], opts.cb)576577account_exists: (opts) =>578opts = defaults opts,579email_address : required580cb : required # cb(err, account_id or undefined) -- actual account_id if it exists; err = problem with db connection...581@_query582query : 'SELECT account_id FROM accounts'583where : "email_address = $::TEXT" : opts.email_address584cb : one_result('account_id', opts.cb)585586# set an account creation action, or return all of them for the given email address587account_creation_actions: (opts) =>588opts = defaults opts,589email_address : required590action : undefined # if given, adds this action; if not, returns all non-expired actions591ttl : 60*60*24*14 # add action with this ttl in seconds (default: 2 weeks)592cb : required # if ttl not given cb(err, [array of actions])593if opts.action?594# add action595@_query596query : 'INSERT INTO account_creation_actions'597values :598'id :: UUID' : misc.uuid()599'email_address :: TEXT' : opts.email_address600'action :: JSONB' : opts.action601'expire :: TIMESTAMP' : expire_time(opts.ttl)602cb : opts.cb603else604# query for actions605@_query606query : 'SELECT action FROM account_creation_actions'607where :608'email_address = $::TEXT' : opts.email_address609'expire >= $::TIMESTAMP' : new Date()610cb : all_results('action', opts.cb)611612account_creation_actions_success: (opts) =>613opts = defaults opts,614account_id : required615cb : required616@_query617query : 'UPDATE accounts'618set :619'creation_actions_done::BOOLEAN' : true620where :621'account_id = $::UUID' : opts.account_id622cb : opts.cb623624# DEPRECATED: use import accountCreationActions from "@cocalc/server/accounts/account-creation-actions"; instead!!!!625do_account_creation_actions: (opts) =>626opts = defaults opts,627email_address : required628account_id : required629cb : required630dbg = @_dbg("do_account_creation_actions(email_address='#{opts.email_address}')")631dbg("**DEPRECATED!** This will miss doing important things, e.g., creating initial project.")632@account_creation_actions633email_address : opts.email_address634cb : (err, actions) =>635if err636opts.cb(err); return637f = (action, cb) =>638dbg("account_creation_actions: action = #{misc.to_json(action)}")639if action.action == 'add_to_project'640@add_user_to_project641project_id : action.project_id642account_id : opts.account_id643group : action.group644cb : (err) =>645if err646dbg("Error adding user to project: #{err}")647cb(err)648else649dbg("ERROR: skipping unknown action -- #{action.action}")650# also store in database so we can look into this later.651@log652event : 'unknown_action'653value :654error : "unknown_action"655action : action656account_id : opts.account_id657host : require('os').hostname()658cb()659async.map actions, f, (err) =>660if not err661@account_creation_actions_success662account_id : opts.account_id663cb : opts.cb664else665opts.cb(err)666667verify_email_create_token: (opts) => # has been rewritten in backend/email/verify.ts668opts = defaults opts,669account_id : required670cb : undefined671672locals =673email_address : undefined674token : undefined675old_challenge : undefined676677async.series([678(cb) =>679@_query680query : "SELECT email_address, email_address_challenge FROM accounts"681where : "account_id = $::UUID" : opts.account_id682cb : one_result (err, x) =>683locals.email_address = x?.email_address684locals.old_challenge = x?.email_address_challenge685cb(err)686(cb) =>687# TODO maybe expire tokens after some time688if locals.old_challenge?689old = locals.old_challenge690# return the same token if there is one for the same email691if old.token? and old.email == locals.email_address692locals.token = locals.old_challenge.token693cb()694return695696{generate} = require("random-key")697locals.token = generate(16).toLowerCase()698data =699email : locals.email_address700token : locals.token701time : new Date()702703@_query704query : "UPDATE accounts"705set :706'email_address_challenge::JSONB' : data707where :708"account_id = $::UUID" : opts.account_id709cb : cb710], (err) ->711opts.cb?(err, locals)712)713714715verify_email_check_token: (opts) => # rewritten in server/auth/redeem-verify-email.ts716opts = defaults opts,717email_address : required718token : required719cb : undefined720721locals =722account_id : undefined723email_address_challenge : undefined724725async.series([726(cb) =>727@get_account728email_address : opts.email_address729columns : ['account_id', 'email_address_challenge']730cb : (err, x) =>731if err732cb(err)733else if not x?734cb("no such email address")735else736locals.account_id = x.account_id737locals.email_address_challenge = x.email_address_challenge738cb()739(cb) =>740if not locals.email_address_challenge?741@is_verified_email742email_address : opts.email_address743cb : (err, verified) ->744if not err and verified745cb("This email address is already verified.")746else747cb("For this email address no account verification is setup.")748749else if locals.email_address_challenge.email != opts.email_address750cb("The account's email address does not match the token's email address.")751752else if locals.email_address_challenge.time < misc.hours_ago(24)753cb("The account verification token is no longer valid. Get a new one!")754755else756if locals.email_address_challenge.token == opts.token757cb()758else759cb("Provided token does not match.")760(cb) =>761# we're good, save it762@_query763query : "UPDATE accounts"764jsonb_set :765email_address_verified:766"#{opts.email_address}" : new Date()767where : "account_id = $::UUID" : locals.account_id768cb : cb769(cb) =>770# now delete the token771@_query772query : 'UPDATE accounts'773set :774'email_address_challenge::JSONB' : null775where :776"account_id = $::UUID" : locals.account_id777cb : cb778], opts.cb)779780# returns the email address and whether or not it is verified781verify_email_get: (opts) =>782opts = defaults opts,783account_id : required784cb : undefined785@_query786query : "SELECT email_address, email_address_verified FROM accounts"787where : "account_id = $::UUID" : opts.account_id788cb : one_result (err, x) ->789opts.cb?(err, x)790791# answers the question as cb(null, [true or false])792is_verified_email: (opts) => # rewritten in server/auth/redeem-verify-email.ts793opts = defaults opts,794email_address : required795cb : required796@get_account797email_address : opts.email_address798columns : ['email_address_verified']799cb : (err, x) =>800if err801opts.cb(err)802else if not x?803opts.cb("no such email address")804else805verified = !!x.email_address_verified?[opts.email_address]806opts.cb(undefined, verified)807808###809Auxiliary billing related queries810###811get_coupon_history: (opts) =>812opts = defaults opts,813account_id : required814cb : undefined815@_dbg("Getting coupon history")816@_query817query : "SELECT coupon_history FROM accounts"818where : 'account_id = $::UUID' : opts.account_id819cb : one_result("coupon_history", opts.cb)820821update_coupon_history: (opts) =>822opts = defaults opts,823account_id : required824coupon_history : required825cb : undefined826@_dbg("Setting to #{opts.coupon_history}")827@_query828query : 'UPDATE accounts'829set : 'coupon_history::JSONB' : opts.coupon_history830where : 'account_id = $::UUID' : opts.account_id831cb : opts.cb832833###834Querying for searchable information about accounts.835###836account_ids_to_usernames: (opts) =>837opts = defaults opts,838account_ids : required839cb : required # (err, mapping {account_id:{first_name:?, last_name:?}})840if not @_validate_opts(opts) then return841if opts.account_ids.length == 0 # easy special case -- don't waste time on a db query842opts.cb(undefined, [])843return844@_query845query : 'SELECT account_id, first_name, last_name FROM accounts'846where : 'account_id = ANY($::UUID[])' : opts.account_ids847cb : (err, result) =>848if err849opts.cb(err)850else851v = misc.dict(([r.account_id, {first_name:r.first_name, last_name:r.last_name}] for r in result.rows))852# fill in unknown users (should never be hit...)853for id in opts.account_ids854if not v[id]?855v[id] = {first_name:undefined, last_name:undefined}856opts.cb(err, v)857858_account_where: (opts) =>859# account_id > email_address > lti_id860if opts.account_id861return {"account_id = $::UUID" : opts.account_id}862else if opts.email_address863return {"email_address = $::TEXT" : opts.email_address}864else if opts.lti_id865return {"lti_id = $::TEXT[]" : opts.lti_id}866else867throw Error("postgres-server-queries::_account_where neither account_id, nor email_address, nor lti_id specified and nontrivial")868869get_account: (opts) =>870opts = defaults opts,871email_address : undefined # provide one of email, account_id, or lti_id (pref is account_id, then email_address, then lti_id)872account_id : undefined873lti_id : undefined874columns : ['account_id',875'password_hash',876'password_is_set', # true or false, depending on whether a password is set (since don't send password_hash to user!)877'first_name',878'last_name',879'email_address',880'evaluate_key',881'autosave',882'terminal',883'editor_settings',884'other_settings',885'groups',886'passports'887]888cb : required889if not @_validate_opts(opts) then return890columns = misc.copy(opts.columns)891if 'password_is_set' in columns892if 'password_hash' not in columns893remove_password_hash = true894columns.push('password_hash')895misc.remove(columns, 'password_is_set')896password_is_set = true897@_query898query : "SELECT #{columns.join(',')} FROM accounts"899where : @_account_where(opts)900cb : one_result (err, z) =>901if err902opts.cb(err)903else if not z?904opts.cb("no such account")905else906if password_is_set907z.password_is_set = !!z.password_hash908if remove_password_hash909delete z.password_hash910for c in columns911if not z[c]? # for same semantics as rethinkdb... (for now)912delete z[c]913opts.cb(undefined, z)914915# check whether or not a user is banned916is_banned_user: (opts) =>917opts = defaults opts,918email_address : undefined919account_id : undefined920cb : required # cb(err, true if banned; false if not banned)921if not @_validate_opts(opts) then return922@_query923query : 'SELECT banned FROM accounts'924where : @_account_where(opts)925cb : one_result('banned', (err, banned) => opts.cb(err, !!banned))926927_touch_account: (account_id, cb) =>928if @_throttle('_touch_account', 120, account_id)929cb()930return931@_query932query : 'UPDATE accounts'933set : {last_active: 'NOW()'}934where : "account_id = $::UUID" : account_id935cb : cb936937_touch_project: (project_id, account_id, cb) =>938if @_throttle('_user_touch_project', 60, project_id, account_id)939cb()940return941NOW = new Date()942@_query943query : "UPDATE projects"944set : {last_edited : NOW}945jsonb_merge : {last_active:{"#{account_id}":NOW}}946where : "project_id = $::UUID" : project_id947cb : cb948949# Indicate activity by a user, possibly on a specific project, and950# then possibly on a specific path in that project.951touch: (opts) =>952opts = defaults opts,953account_id : required954project_id : undefined955path : undefined956action : 'edit'957ttl_s : 50 # min activity interval; calling this function with same input again within this interval is ignored958cb : undefined959if opts.ttl_s960if @_throttle('touch', opts.ttl_s, opts.account_id, opts.project_id, opts.path, opts.action)961opts.cb?()962return963964now = new Date()965async.parallel([966(cb) =>967@_touch_account(opts.account_id, cb)968(cb) =>969if not opts.project_id?970cb(); return971@_touch_project(opts.project_id, opts.account_id, cb)972(cb) =>973if not opts.path? or not opts.project_id?974cb(); return975@record_file_use(project_id:opts.project_id, path:opts.path, action:opts.action, account_id:opts.account_id, cb:cb)976], (err)->opts.cb?(err))977978979# Invalidate all outstanding remember me cookies for the given account by980# deleting them from the remember_me key:value store.981invalidate_all_remember_me: (opts) =>982opts = defaults opts,983account_id : undefined984email_address : undefined985cb : undefined986if not @_validate_opts(opts) then return987@_query988query : 'DELETE FROM remember_me'989where : @_account_where(opts)990cb : opts.cb991992# Get remember me cookie with given hash. If it has expired,993# **get back undefined instead**. (Actually deleting expired).994# We use retry_until_success, since an intermittent database995# reconnect can result in a cb error that will very soon996# work fine, and we don't to flat out sign the client out997# just because of this.998get_remember_me: (opts) =>999opts = defaults opts,1000hash : required1001cache : true1002cb : required # cb(err, signed_in_message | undefined)1003account_id = undefined1004try1005account_id = await get_remember_me(@, opts.hash, opts.cache)1006catch err1007opts.cb(err)1008return1009if account_id1010opts.cb(undefined, {event:"signed_in", account_id:account_id})1011else1012opts.cb()10131014delete_remember_me: (opts) =>1015opts = defaults opts,1016hash : required1017cb : undefined1018@_query1019query : 'DELETE FROM remember_me'1020where :1021'hash = $::TEXT' : opts.hash.slice(0,127)1022cb : opts.cb10231024# ASYNC FUNCTION1025get_personal_user: () =>1026return await get_personal_user(@)10271028###1029# Changing password/email, etc. sensitive info about a user1030###10311032# Change the password for the given account.1033change_password: (opts={}) =>1034opts = defaults opts,1035account_id : required1036password_hash : required1037invalidate_remember_me : true1038cb : required1039if not @_validate_opts(opts) then return1040if opts.password_hash.length > 1731041opts.cb("password_hash must be at most 173 characters")1042return1043async.series([ # don't do in parallel -- don't kill remember_me if password failed!1044(cb) =>1045@_query1046query : 'UPDATE accounts'1047set : {password_hash : opts.password_hash}1048where : @_account_where(opts)1049cb : cb1050(cb) =>1051if opts.invalidate_remember_me1052@invalidate_all_remember_me1053account_id : opts.account_id1054cb : cb1055else1056cb()1057], opts.cb)10581059# Reset Password MEANT FOR INTERACTIVE USE -- if password is not given, will prompt for it.1060reset_password: (opts) =>1061opts = defaults opts,1062email_address : undefined1063account_id : undefined1064password : undefined1065random : true # if true (the default), will generate and print a random password.1066cb : undefined1067dbg = @_dbg("reset_password")1068async.series([1069(cb) =>1070if opts.account_id?1071cb()1072return1073@get_account1074email_address : opts.email_address1075columns : ['account_id']1076cb : (err, data) =>1077opts.account_id = data?.account_id1078cb(err)1079(cb) =>1080if opts.password?1081cb()1082return1083if opts.random1084require('crypto').randomBytes 16, (err, buffer) =>1085opts.password = buffer.toString('hex')1086cb()1087return1088read {prompt:'Password: ', silent:true}, (err, passwd) =>1089opts.passwd0 = passwd; cb(err)1090(cb) =>1091if opts.password?1092cb()1093return1094read {prompt:'Retype password: ', silent:true}, (err, passwd1) =>1095if err1096cb(err)1097else1098if passwd1 != opts.passwd01099cb("Passwords do not match.")1100else1101opts.password = passwd11102cb()1103(cb) =>1104# change the user's password in the database.1105@change_password1106account_id : opts.account_id1107password_hash : passwordHash(opts.password)1108cb : cb1109], (err) =>1110if err1111console.warn("Error -- #{err}")1112else1113console.log("Password changed for #{opts.email_address}")1114if opts.random1115console.log("Random Password:\n\n\t\t#{opts.password}\n\n")1116opts.cb?(err)1117)11181119# Change the email address, unless the email_address we're changing to is already taken.1120# If there is a stripe customer ID, we also call the update process to maybe sync the changed email address1121change_email_address: (opts={}) =>1122opts = defaults opts,1123account_id : required1124email_address : required1125stripe : required1126cb : required1127if not @_validate_opts(opts) then return1128async.series([1129(cb) =>1130@account_exists1131email_address : opts.email_address1132cb : (err, exists) =>1133if err1134cb(err)1135return1136if exists1137cb("email_already_taken")1138return1139cb()1140(cb) =>1141@_query1142query : 'UPDATE accounts'1143set : {email_address: opts.email_address}1144where : @_account_where(opts)1145cb : cb1146(cb) =>1147@_query1148query : "SELECT stripe_customer_id FROM accounts"1149where : "account_id = $::UUID" : opts.account_id1150cb : one_result (err, x) =>1151if err1152cb(err)1153return1154if x.stripe_customer_id1155try1156await syncCustomer1157account_id : opts.account_id1158stripe : opts.stripe1159customer_id : x.stripe_customer_id1160cb()1161catch err1162cb(err)1163else1164cb()1165], (err) =>1166opts.cb(err)1167)11681169###1170User auth token1171###1172# save an auth token in the database1173save_auth_token: (opts) =>1174opts = defaults opts,1175account_id : required1176auth_token : required1177ttl : 12*3600 # ttl in seconds (default: 12 hours)1178cb : required1179if not @_validate_opts(opts) then return1180@_query1181query : 'INSERT INTO auth_tokens'1182values :1183'auth_token :: CHAR(24) ' : opts.auth_token1184'expire :: TIMESTAMP ' : expire_time(opts.ttl)1185'account_id :: UUID ' : opts.account_id1186cb : opts.cb11871188# Get account_id of account with given auth_token. If it1189# is not defined, get back undefined instead.1190get_auth_token_account_id: (opts) =>1191opts = defaults opts,1192auth_token : required1193cb : required # cb(err, account_id)1194@_query1195query : 'SELECT account_id, expire FROM auth_tokens'1196where :1197'auth_token = $::CHAR(24)' : opts.auth_token1198cb : one_result (err, x) =>1199if err1200opts.cb(err)1201else if not x?1202opts.cb() # nothing1203else if x.expire <= new Date()1204opts.cb()1205else1206opts.cb(undefined, x.account_id)12071208delete_auth_token: (opts) =>1209opts = defaults opts,1210auth_token : required1211cb : undefined # cb(err)1212@_query1213query : 'DELETE FROM auth_tokens'1214where :1215'auth_token = $::CHAR(24)' : opts.auth_token1216cb : opts.cb121712181219###1220Password reset1221###1222set_password_reset: (opts) =>1223opts = defaults opts,1224email_address : required1225ttl : required1226cb : required # cb(err, uuid)1227id = misc.uuid()1228@_query1229query : "INSERT INTO password_reset"1230values :1231"id :: UUID" : id1232"email_address :: TEXT" : opts.email_address1233"expire :: TIMESTAMP" : expire_time(opts.ttl)1234cb : (err) =>1235opts.cb(err, id)12361237get_password_reset: (opts) =>1238opts = defaults opts,1239id : required1240cb : required # cb(err, true if allowed and false if not)1241@_query1242query : 'SELECT expire, email_address FROM password_reset'1243where : 'id = $::UUID': opts.id1244cb : one_result('email_address', opts.cb)12451246delete_password_reset: (opts) =>1247opts = defaults opts,1248id : required1249cb : required # cb(err, true if allowed and false if not)1250@_query1251query : 'DELETE FROM password_reset'1252where : 'id = $::UUID': opts.id1253cb : opts.cb12541255record_password_reset_attempt: (opts) =>1256opts = defaults opts,1257email_address : required1258ip_address : required1259ttl : required1260cb : required # cb(err)1261@_query1262query : 'INSERT INTO password_reset_attempts'1263values :1264"id :: UUID" : misc.uuid()1265"email_address :: TEXT " : opts.email_address1266"ip_address :: INET" : opts.ip_address1267"time :: TIMESTAMP" : "NOW()"1268"expire :: TIMESTAMP" : expire_time(opts.ttl)1269cb : opts.cb12701271count_password_reset_attempts: (opts) =>1272opts = defaults opts,1273email_address : undefined # must give one of email_address or ip_address1274ip_address : undefined1275age_s : required # at most this old1276cb : required # cb(err)1277@_query1278query : 'SELECT COUNT(*) FROM password_reset_attempts'1279where :1280'time >= $::TIMESTAMP' : misc.seconds_ago(opts.age_s)1281'email_address = $::TEXT ' : opts.email_address1282'ip_address = $::INET ' : opts.ip_address1283cb : count_result(opts.cb)12841285###1286Tracking file access12871288log_file_access is throttled in each server, in the sense that1289if it is called with the same input within a minute, those1290subsequent calls are ignored. Of course, if multiple servers1291are recording file_access then there can be more than one1292entry per minute.1293###1294log_file_access: (opts) =>1295opts = defaults opts,1296project_id : required1297account_id : required1298filename : required1299cb : undefined1300if not @_validate_opts(opts) then return1301if @_throttle('log_file_access', 60, opts.project_id, opts.account_id, opts.filename)1302opts.cb?()1303return13041305# If expire no pii expiration is set, use 1 year as a fallback1306expire = await pii_expire(@) ? expire_time(365*24*60*60)13071308@_query1309query : 'INSERT INTO file_access_log'1310values :1311'id :: UUID ' : misc.uuid()1312'project_id :: UUID ' : opts.project_id1313'account_id :: UUID ' : opts.account_id1314'filename :: TEXT ' : opts.filename1315'time :: TIMESTAMP' : 'NOW()'1316'expire :: TIMESTAMP' : expire1317cb : opts.cb13181319###1320Efficiently get all files access times subject to various constraints...13211322NOTE: this was not available in RethinkDB version (too painful to implement!), but here it is,1323easily sliceable in any way. This could be VERY useful for users!1324###1325get_file_access: (opts) =>1326opts = defaults opts,1327start : undefined # start time1328end : undefined # end time1329project_id : undefined1330account_id : undefined1331filename : undefined1332cb : required1333@_query1334query : 'SELECT project_id, account_id, filename, time FROM file_access_log'1335where :1336'time >= $::TIMESTAMP' : opts.start1337'time <= $::TIMESTAMP' : opts.end1338'project_id = $::UUID' : opts.project_id1339'account_id = $::UUID' : opts.account_id1340'filename = $::TEXT' : opts.filename1341cb : all_results(opts.cb)13421343###1344File editing activity -- users modifying files in any way1345- one single table called file_activity1346- table also records info about whether or not activity has been seen by users1347###1348record_file_use: (opts) =>1349opts = defaults opts,1350project_id : required1351path : required1352account_id : required1353action : required # 'edit', 'read', 'seen', 'chat', etc.?1354cb : required1355# Doing what's done below (with two queries) is really, really ugly.1356# See comment in db-schema.coffee about file_use table -- will redo1357# for postgres later...1358now = new Date()1359entry =1360id : @sha1(opts.project_id, opts.path)1361project_id : opts.project_id1362path : opts.path1363if opts.action == 'edit' or opts.action == 'chat'1364entry.last_edited = now1365async.series([1366(cb) =>1367@_query1368query : 'INSERT INTO file_use'1369conflict : 'id'1370values : entry1371cb : cb1372(cb) =>1373@_query1374query : 'UPDATE file_use'1375jsonb_merge :1376users : {"#{opts.account_id}": {"#{opts.action}": now}}1377where : {id : entry.id}1378cb : cb1379], opts.cb)13801381get_file_use: (opts) =>1382opts = defaults opts,1383max_age_s : undefined1384project_id : undefined # don't specify both project_id and project_ids1385project_ids : undefined1386path : undefined # if given, project_id must be given1387cb : required # one entry if path given; otherwise, an array of entries.1388if opts.project_id?1389if opts.project_ids?1390opts.cb("don't specify both project_id and project_ids")1391return1392else1393opts.project_ids = [opts.project_id]1394else if not opts.project_ids?1395opts.cb("project_id or project_ids must be defined")1396return1397@_query1398query : 'SELECT * FROM file_use'1399where :1400'last_edited >= $::TIMESTAMP' : if opts.max_age_s then misc.seconds_ago(opts.max_age_s)1401'project_id = ANY($)' : opts.project_ids1402'path = $::TEXT' : opts.path1403order_by : 'last_edited'1404cb : if opts.path? then one_result(opts.cb) else all_results(opts.cb)14051406_validate_opts: (opts) =>1407for k, v of opts1408if k == 'lti_id'1409if not (Array.isArray(v) and v.length > 0)1410opts.cb?("invalid #{k} -- can't be an empty array")1411return false1412for x in v1413if not (typeof x == 'string' and x.length > 0)1414opts.cb?("invalid #{k} -- #{v}")1415return false1416else if k.slice(k.length-2) == 'id'1417if v? and not misc.is_valid_uuid_string(v)1418opts.cb?("invalid #{k} -- #{v}")1419return false1420if k.slice(k.length-3) == 'ids'1421for w in v1422if not misc.is_valid_uuid_string(w)1423opts.cb?("invalid uuid #{w} in #{k} -- #{misc.to_json(v)}")1424return false1425if k == 'group' and v not in misc.PROJECT_GROUPS1426opts.cb?("unknown project group '#{v}'"); return false1427if k == 'groups'1428for w in v1429if w not in misc.PROJECT_GROUPS1430opts.cb?("unknown project group '#{w}' in groups"); return false14311432return true14331434get_project: (opts) =>1435opts = defaults opts,1436project_id : required # an array of id's1437columns : PROJECT_COLUMNS1438cb : required1439if not @_validate_opts(opts) then return1440@_query1441query : "SELECT #{opts.columns.join(',')} FROM projects"1442where : 'project_id :: UUID = $' : opts.project_id1443cb : one_result(opts.cb)14441445_get_project_column: (column, project_id, cb) =>1446if not misc.is_valid_uuid_string(project_id)1447cb("invalid project_id -- #{project_id}: getting column #{column}")1448return1449@_query1450query : "SELECT #{column} FROM projects"1451where : 'project_id :: UUID = $' : project_id1452cb : one_result(column, cb)14531454get_user_column: (column, account_id, cb) =>1455if not misc.is_valid_uuid_string(account_id)1456cb("invalid account_id -- #{account_id}: getting column #{column}")1457return1458@_query1459query : "SELECT #{column} FROM accounts"1460where : 'account_id :: UUID = $' : account_id1461cb : one_result(column, cb)14621463add_user_to_project: (opts) =>1464opts = defaults opts,1465project_id : required1466account_id : required1467group : 'collaborator' # see misc.PROJECT_GROUPS above1468cb : required # cb(err)14691470if not @_validate_opts(opts) then return14711472@_query1473query : 'UPDATE projects'1474jsonb_merge :1475users :1476"#{opts.account_id}":1477group: opts.group1478where :1479"project_id = $::UUID": opts.project_id1480cb : opts.cb14811482set_project_status: (opts) =>1483opts = defaults opts,1484project_id : required1485status : required1486cb : undefined1487@_query1488query : "UPDATE projects"1489set : {"status::JSONB" : opts.status}1490where : {"project_id = $::UUID": opts.project_id}1491cb : opts.cb149214931494# Remove the given collaborator from the project.1495# Attempts to remove an *owner* via this function will silently fail (change their group first),1496# as will attempts to remove a user not on the project, or to remove from a non-existent project.1497remove_collaborator_from_project: (opts) =>1498opts = defaults opts,1499project_id : required1500account_id : required1501cb : required # cb(err)1502if not @_validate_opts(opts) then return1503@_query1504query : 'UPDATE projects'1505jsonb_set : {users : {"#{opts.account_id}": null}}1506where :1507'project_id :: UUID = $' : opts.project_id1508"users#>>'{#{opts.account_id},group}' != $::TEXT" : 'owner'1509cb : opts.cb15101511# remove any user, even an owner.1512remove_user_from_project: (opts) =>1513opts = defaults opts,1514project_id : required1515account_id : required1516cb : required # cb(err)1517if not @_validate_opts(opts) then return1518@_query1519query : 'UPDATE projects'1520jsonb_set : {users : {"#{opts.account_id}": null}}1521where : {'project_id :: UUID = $' : opts.project_id}1522cb : opts.cb15231524# Return a list of the account_id's of all collaborators of the given users.1525get_collaborator_ids: (opts) =>1526opts = defaults opts,1527account_id : required1528cb : required1529dbg = @_dbg("get_collaborator_ids")1530@_query1531query : "SELECT DISTINCT jsonb_object_keys(users) FROM projects"1532where : "users ? $::TEXT" : opts.account_id1533cb : all_results('jsonb_object_keys', opts.cb)15341535# get list of project collaborator IDs1536get_collaborators: (opts) =>1537opts = defaults opts,1538project_id : required1539cb : required1540dbg = @_dbg("get_collaborators")1541@_query1542query : "SELECT DISTINCT jsonb_object_keys(users) FROM projects"1543where : "project_id = $::UUID" : opts.project_id1544cb : all_results('jsonb_object_keys', opts.cb)154515461547# return list of paths that are public and not disabled in the given project1548get_public_paths: (opts) =>1549opts = defaults opts,1550project_id : required1551cb : required1552if not @_validate_opts(opts) then return1553@_query1554query : "SELECT path FROM public_paths"1555where : [1556"project_id = $::UUID" : opts.project_id,1557"disabled IS NOT TRUE"1558]1559cb : all_results('path', opts.cb)15601561has_public_path: (opts) =>1562opts = defaults opts,1563project_id : required1564cb : required # cb(err, has_public_path)1565@_query1566query : "SELECT COUNT(path) FROM public_paths"1567where : [1568"project_id = $::UUID" : opts.project_id,1569"disabled IS NOT TRUE"1570]1571cb : count_result (err, n) ->1572opts.cb(err, n>0)15731574path_is_public: (opts) =>1575opts = defaults opts,1576project_id : required1577path : required1578cb : required1579# Get all public paths for the given project_id, then check if path is "in" one according1580# to the definition in misc.1581# TODO: maybe (?) implement caching + changefeeds so that we only do the get once.1582@get_public_paths1583project_id : opts.project_id1584cb : (err, public_paths) =>1585if err1586opts.cb(err)1587else1588opts.cb(undefined, misc.path_is_in_public_paths(opts.path, public_paths))15891590filter_public_paths: (opts) =>1591opts = defaults opts,1592project_id : required1593path : required1594listing : required # files in path [{name:..., isdir:boolean, ....}, ...]1595cb : required1596# Get all public paths for the given project_id, then check if path is "in" one according1597# to the definition in misc.1598@get_public_paths1599project_id : opts.project_id1600cb : (err, public_paths) =>1601if err1602opts.cb(err)1603return1604if misc.path_is_in_public_paths(opts.path, public_paths)1605# nothing to do -- containing path is public1606listing = opts.listing1607else1608listing = misc.deep_copy(opts.listing) # don't mututate input on general principle1609# some files in the listing might not be public, since the containing path isn't public, so we filter1610# WARNING: this is kind of stupid since misc.path_is_in_public_paths is badly implemented, especially1611# for this sort of iteration. TODO: make this faster. This could matter since is done on server.1612try1613# we use try/catch here since there is no telling what is in the listing object; the user1614# could pass in anything...1615listing.files = (x for x in listing.files when \1616misc.path_is_in_public_paths(misc.path_to_file(opts.path, x.name), public_paths))1617catch1618listing.files = []1619opts.cb(undefined, listing)16201621# Set last_edited for this project to right now, and possibly update its size.1622# It is safe and efficient to call this function very frequently since it will1623# actually hit the database at most once every 30s (per project, per client). In particular,1624# once called, it ignores subsequent calls for the same project for 30s.1625touch_project: (opts) =>1626opts = defaults opts,1627project_id : required1628cb : undefined1629if not @_validate_opts(opts) then return1630if @_throttle('touch_project', 30, opts.project_id)1631opts.cb?()1632return1633@_query1634query : "UPDATE projects"1635set : {last_edited : 'NOW()'}1636where : "project_id = $::UUID" : opts.project_id1637cb : opts.cb16381639recently_modified_projects: (opts) =>1640opts = defaults opts,1641max_age_s : required1642cb : required1643@_query1644query : "SELECT project_id FROM projects"1645where : "last_edited >= $::TIMESTAMP" : misc.seconds_ago(opts.max_age_s)1646cb : all_results('project_id', opts.cb)16471648get_open_unused_projects: (opts) =>1649opts = defaults opts,1650min_age_days : 30 # project must not have been edited in this much time1651max_age_days : 120 # project must have been edited at most this long ago1652host : required # hostname of where project is opened1653cb : required1654@_query1655query : "SELECT project_id FROM projects"1656where : [1657"last_edited >= $::TIMESTAMP" : misc.days_ago(opts.max_age_days)1658"last_edited <= $::TIMESTAMP" : misc.days_ago(opts.min_age_days)1659"host#>>'{host}' = $::TEXT " : opts.host,1660"state#>>'{state}' = 'opened'"1661]1662cb : all_results('project_id', opts.cb)16631664# cb(err, true if user is in one of the groups for the project **or an admin**)1665user_is_in_project_group: (opts) =>1666opts = defaults opts,1667project_id : required1668account_id : undefined1669groups : ['owner', 'collaborator']1670cache : false # if true cache result for a few seconds1671cb : required # cb(err, true if in group)1672if not opts.account_id?1673# clearly user -- who isn't even signed in -- is not in the group1674opts.cb(undefined, false)1675return1676if not @_validate_opts(opts) then return1677@_query1678query : 'SELECT COUNT(*) FROM projects'1679cache : opts.cache1680where :1681'project_id :: UUID = $' : opts.project_id1682"users#>>'{#{opts.account_id},group}' = ANY($)" : opts.groups1683cb : count_result (err, n) =>1684if err1685opts.cb(err)1686else if n == 01687# one more chance -- admin?1688@is_admin1689account_id : opts.account_id1690cb : opts.cb1691else1692opts.cb(err, n > 0)16931694# cb(err, true if user is an actual collab; ADMINS do not count)1695user_is_collaborator: (opts) =>1696opts = defaults opts,1697project_id : required1698account_id : required1699cache : true1700cb : required # cb(err, true if is actual collab on project)1701if not @_validate_opts(opts) then return1702@_query1703query : 'SELECT COUNT(*) FROM projects'1704cache : opts.cache1705where : ['project_id :: UUID = $1', "users ? $2"]1706params: [opts.project_id, opts.account_id]1707cb : count_result (err, n) =>1708if err1709opts.cb(err)1710else1711opts.cb(err, n > 0)17121713# all id's of projects having anything to do with the given account1714get_project_ids_with_user: (opts) =>1715opts = defaults opts,1716account_id : required1717is_owner : undefined # if set to true, only return projects with this owner.1718cb : required # opts.cb(err, [project_id, project_id, project_id, ...])1719if not @_validate_opts(opts) then return17201721if opts.is_owner1722where = {"users#>>'{#{opts.account_id},group}' = $::TEXT" : 'owner'}1723else1724where = {'users ? $::TEXT' : opts.account_id}1725@_query1726query : 'SELECT project_id FROM projects'1727where : where1728cb : all_results('project_id', opts.cb)17291730# cb(err, array of account_id's of accounts in non-invited-only groups)1731# TODO: add something about invited users too and show them in UI!1732get_account_ids_using_project: (opts) =>1733opts = defaults opts,1734project_id : required1735cb : required1736if not @_validate_opts(opts) then return1737@_query1738query : 'SELECT users FROM projects'1739where : 'project_id :: UUID = $' : opts.project_id1740cb : one_result 'users', (err, users) =>1741if err1742opts.cb(err)1743return1744opts.cb(undefined, if users? then (id for id,v of users when v.group?.indexOf('invite') == -1) else [])17451746# Have we successfully (no error) sent an invite to the given email address?1747# If so, returns timestamp of when.1748# If not, returns 0.1749when_sent_project_invite: (opts) =>1750opts = defaults opts,1751project_id : required1752to : required # an email address1753cb : required1754if not @_validate_opts(opts) then return1755# in particular, emails like bla'[email protected] → bla''[email protected]1756sani_to = @sanitize("{\"#{opts.to}\"}")1757query_select = "SELECT invite#>#{sani_to} AS to FROM projects"1758@_query1759query : query_select1760where : 'project_id :: UUID = $' : opts.project_id1761cb : one_result 'to', (err, y) =>1762opts.cb(err, if not y? or y.error or not y.time then 0 else new Date(y.time))17631764# call this to record that we have sent an email invite to the given email address1765sent_project_invite: (opts) =>1766opts = defaults opts,1767project_id : required1768to : required # an email address1769error : undefined # if there was an error set it to this; leave undefined to mean that sending succeeded1770cb : undefined1771x = {time: new Date()}1772if opts.error?1773x.error = opts.error1774@_query1775query : "UPDATE projects"1776jsonb_merge :1777{invite : "#{opts.to}" : {time: new Date(), error:opts.error}}1778where : 'project_id :: UUID = $' : opts.project_id1779cb : opts.cb17801781###1782Project host, storage location, and state.1783###1784set_project_host: (opts) =>1785opts = defaults opts,1786project_id : required1787host : required1788cb : required1789assigned = new Date()1790@_query1791query : "UPDATE projects"1792jsonb_set :1793host : {host:opts.host, assigned:assigned}1794where : 'project_id :: UUID = $' : opts.project_id1795cb : (err) => opts.cb(err, assigned)17961797unset_project_host: (opts) =>1798opts = defaults opts,1799project_id : required1800cb : required1801@_query1802query : "UPDATE projects"1803set :1804host : null1805where : 'project_id :: UUID = $' : opts.project_id1806cb : opts.cb18071808get_project_host: (opts) =>1809opts = defaults opts,1810project_id : required1811cb : required1812@_query1813query : "SELECT host#>>'{host}' AS host FROM projects"1814where : 'project_id :: UUID = $' : opts.project_id1815cb : one_result('host', opts.cb)18161817set_project_storage: (opts) =>1818opts = defaults opts,1819project_id : required1820host : required1821cb : required1822@get_project_storage1823project_id : opts.project_id1824cb : (err, current) =>1825if err1826opts.cb(err)1827return1828if current?.host? and current.host != opts.host1829opts.cb("change storage not implemented yet -- need to implement saving previous host")1830else1831# easy case -- assigning for the first time1832assigned = new Date()1833@_query1834query : "UPDATE projects"1835jsonb_set :1836storage : {host:opts.host, assigned:assigned}1837where : 'project_id :: UUID = $' : opts.project_id1838cb : (err) => opts.cb(err, assigned)18391840get_project_storage: (opts) =>1841opts = defaults opts,1842project_id : required1843cb : required1844@_get_project_column('storage', opts.project_id, opts.cb)18451846update_project_storage_save: (opts) =>1847opts = defaults opts,1848project_id : required1849cb : required1850@_query1851query : "UPDATE projects"1852jsonb_merge :1853storage : {saved:new Date()}1854where : 'project_id :: UUID = $' : opts.project_id1855cb : opts.cb18561857set_project_storage_request: (opts) =>1858opts = defaults opts,1859project_id : required1860action : required # 'save', 'close', 'open', 'move'1861target : undefined # needed for 'open' and 'move'1862cb : required1863x =1864action : opts.action1865requested : new Date()1866if opts.target?1867x.target = opts.target1868@_query1869query : "UPDATE projects"1870set :1871"storage_request::JSONB" : x1872where : 'project_id :: UUID = $' : opts.project_id1873cb : opts.cb18741875get_project_storage_request: (opts) =>1876opts = defaults opts,1877project_id : required1878cb : required1879@_get_project_column('storage_request', opts.project_id, opts.cb)18801881set_project_state: (opts) =>1882opts = defaults opts,1883project_id : required1884state : required1885time : new Date()1886error : undefined1887ip : undefined # optional ip address1888cb : required1889if typeof(opts.state) != 'string'1890opts.cb("invalid state type")1891return1892if not COMPUTE_STATES[opts.state]?1893opts.cb("state = '#{opts.state}' it not a valid state")1894return1895state =1896state : opts.state1897time : opts.time1898if opts.error1899state.error = opts.error1900if opts.ip1901state.ip = opts.ip1902@_query1903query : "UPDATE projects"1904set : "state::JSONB" : state1905where : 'project_id :: UUID = $' : opts.project_id1906cb : opts.cb19071908get_project_state: (opts) =>1909opts = defaults opts,1910project_id : required1911cb : required1912@_get_project_column('state', opts.project_id, opts.cb)19131914###1915Project quotas and upgrades1916###19171918# Returns the total quotas for the project, including any1919# upgrades to the base settings.1920get_project_quotas: (opts) =>1921opts = defaults opts,1922project_id : required1923cb : required1924settings = users = site_license = server_settings = undefined1925async.parallel([1926(cb) =>1927@_query1928query : 'SELECT settings, users, site_license FROM projects'1929where : 'project_id = $::UUID' : opts.project_id1930cb : one_result (err, x) =>1931settings = x.settings1932site_license = x.site_license1933users = x.users1934cb(err)1935(cb) =>1936@get_server_settings_cached1937cb : (err, x) =>1938server_settings = x1939cb(err)1940], (err) =>1941if err1942opts.cb(err)1943else1944upgrades = quota(settings, users, site_license, server_settings)1945opts.cb(undefined, upgrades)1946)19471948# Return mapping from project_id to map listing the upgrades this particular user1949# applied to the given project. This only includes project_id's of projects that1950# this user may have upgraded in some way.1951get_user_project_upgrades: (opts) =>1952opts = defaults opts,1953account_id : required1954cb : required1955@_query1956query : "SELECT project_id, users#>'{#{opts.account_id},upgrades}' AS upgrades FROM projects"1957where : [1958'users ? $::TEXT' : opts.account_id, # this is a user of the project1959"users#>'{#{opts.account_id},upgrades}' IS NOT NULL" # upgrades are defined1960]1961cb : (err, result) =>1962if err1963opts.cb(err)1964else1965x = {}1966for p in result.rows1967x[p.project_id] = p.upgrades1968opts.cb(undefined, x)19691970# Ensure that all upgrades applied by the given user to projects are consistent,1971# truncating any that exceed their allotment. NOTE: Unless there is a bug,1972# the only way the quotas should ever exceed their allotment would be if the1973# user is trying to cheat... *OR* a subscription was canceled or ended.1974ensure_user_project_upgrades_are_valid: (opts) =>1975opts = defaults opts,1976account_id : required1977fix : true # if true, will fix projects in database whose quotas exceed the allotted amount; it is the caller's responsibility to actually change them.1978cb : required # cb(err, excess)1979dbg = @_dbg("ensure_user_project_upgrades_are_valid(account_id='#{opts.account_id}')")1980dbg()1981excess = stripe_data = project_upgrades = undefined1982async.series([1983(cb) =>1984async.parallel([1985(cb) =>1986@_query1987query : 'SELECT stripe_customer FROM accounts'1988where : 'account_id = $::UUID' : opts.account_id1989cb : one_result 'stripe_customer', (err, stripe_customer) =>1990stripe_data = stripe_customer?.subscriptions?.data1991cb(err)1992(cb) =>1993@get_user_project_upgrades1994account_id : opts.account_id1995cb : (err, x) =>1996project_upgrades = x1997cb(err)1998], cb)1999(cb) =>2000excess = require('@cocalc/util/upgrades').available_upgrades(stripe_data, project_upgrades).excess2001if opts.fix2002fix = (project_id, cb) =>2003dbg("fixing project_id='#{project_id}' with excess #{JSON.stringify(excess[project_id])}")2004upgrades = undefined2005async.series([2006(cb) =>2007@_query2008query : "SELECT users#>'{#{opts.account_id},upgrades}' AS upgrades FROM projects"2009where : 'project_id = $::UUID' : project_id2010cb : one_result 'upgrades', (err, x) =>2011upgrades = x; cb(err)2012(cb) =>2013if not upgrades?2014cb(); return2015# WORRY: this is dangerous since if something else changed about a user2016# between the read/write here, then we would have trouble. (This is milliseconds of time though...)2017for k, v of excess[project_id]2018upgrades[k] -= v2019@_query2020query : "UPDATE projects"2021where : 'project_id = $::UUID' : project_id2022jsonb_merge :2023users : {"#{opts.account_id}": {upgrades: upgrades}}2024cb : cb2025], cb)2026async.map(misc.keys(excess), fix, cb)2027else2028cb()2029], (err) =>2030opts.cb(err, excess)2031)20322033# Loop through every user of cocalc that is connected with stripe (so may have a subscription),2034# and ensure that any upgrades that have applied to projects are valid. It is important to2035# run this periodically or there is a really natural common case where users can cheat:2036# (1) they apply upgrades to a project2037# (2) their subscription expires2038# (3) they do NOT touch upgrades on any projects again.2039ensure_all_user_project_upgrades_are_valid: (opts) =>2040opts = defaults opts,2041limit : 1 # We only default to 1 at a time, since there is no hurry.2042cb : required2043dbg = @_dbg("ensure_all_user_project_upgrades_are_valid")2044locals = {}2045async.series([2046(cb) =>2047@_query2048query : "SELECT account_id FROM accounts"2049where : "stripe_customer_id IS NOT NULL"2050timeout_s: 3002051cb : all_results 'account_id', (err, account_ids) =>2052locals.account_ids = account_ids2053cb(err)2054(cb) =>2055m = 02056n = locals.account_ids.length2057dbg("got #{n} accounts with stripe")2058f = (account_id, cb) =>2059m += 12060dbg("#{m}/#{n}")2061@ensure_user_project_upgrades_are_valid2062account_id : account_id2063cb : cb2064async.mapLimit(locals.account_ids, opts.limit, f, cb)2065], opts.cb)20662067# Return the sum total of all user upgrades to a particular project2068get_project_upgrades: (opts) =>2069opts = defaults opts,2070project_id : required2071cb : required2072@_query2073query : 'SELECT users FROM projects'2074where : 'project_id = $::UUID' : opts.project_id2075cb : one_result 'users', (err, users) =>2076if err2077opts.cb(err); return2078upgrades = undefined2079if users?2080for account_id, info of users2081upgrades = misc.map_sum(upgrades, info.upgrades)2082opts.cb(undefined, upgrades)20832084# Remove all upgrades to all projects applied by this particular user.2085remove_all_user_project_upgrades: (opts) =>2086opts = defaults opts,2087account_id : required2088projects : undefined # if given, only remove from projects with id in this array.2089cb : required2090if not misc.is_valid_uuid_string(opts.account_id)2091opts.cb("invalid account_id")2092return2093query = "UPDATE projects SET users=jsonb_set(users, '{#{opts.account_id}}', jsonb(users#>'{#{opts.account_id}}') - 'upgrades')"2094where = [2095'users ? $::TEXT' : opts.account_id, # this is a user of the project2096"users#>'{#{opts.account_id},upgrades}' IS NOT NULL" # upgrades are defined2097]2098if opts.projects2099if not misc.is_array(opts.projects)2100opts.cb("projects must be an array")2101return2102w = []2103for project_id in opts.projects2104if not misc.is_valid_uuid_string(project_id)2105opts.cb('each entry in projects must be a valid uuid')2106return2107w.push("'#{project_id}'")2108where.push("project_id in (#{w.join(',')})")21092110@_query2111query : query2112where : where2113cb: opts.cb2114# TODO: any impacted project that is currently running should also (optionally?) get restarted.2115# I'm not going to bother for now, but this DOES need to get implemented, since otherwise users2116# can cheat too easily. Alternatively, have a periodic control loop on all running projects that2117# confirms that everything is legit (and remove the verification code for user_query) --2118# that's probably better. This could be a service called manage-upgrades.21192120###2121Project settings2122###2123get_project_settings: (opts) =>2124opts = defaults opts,2125project_id : required2126cb : required2127@_query2128query : "SELECT settings FROM projects"2129where : 'project_id = $::UUID' : opts.project_id2130cb : one_result 'settings', (err, settings) =>2131if err2132opts.cb(err)2133else if not settings?2134opts.cb(undefined, misc.copy(DEFAULT_QUOTAS))2135else2136settings = misc.coerce_codomain_to_numbers(settings)2137quotas = {}2138for k, v of DEFAULT_QUOTAS2139quotas[k] = if not settings[k]? then v else settings[k]2140opts.cb(undefined, quotas)21412142set_project_settings: (opts) =>2143opts = defaults opts,2144project_id : required2145settings : required # can be any subset of the map2146cb : required2147@_query2148query : "UPDATE projects"2149where : 'project_id = $::UUID' : opts.project_id2150jsonb_merge : {settings: opts.settings}2151cb : opts.cb21522153get_project_extra_env: (opts) =>2154opts = defaults opts,2155project_id : required2156cb : required2157@_query2158query : "SELECT env FROM projects"2159where : 'project_id = $::UUID' : opts.project_id2160cb : one_result 'env', (err, env) =>2161if err2162opts.cb(err)2163else2164opts.cb(undefined, env ? {})216521662167recent_projects: (opts) =>2168opts = defaults opts,2169age_m : required # return results at most this old2170min_age_m : 0 # only returns results at least this old2171pluck : undefined # if not given, returns list of project_id's; if given (as an array), returns objects with these fields2172cb : required # cb(err, list of strings or objects)21732174if opts.pluck?2175columns = opts.pluck.join(',')2176cb = all_results(opts.cb)2177else2178columns = 'project_id'2179cb = all_results('project_id', opts.cb)2180@_query2181query : "SELECT #{columns} FROM projects"2182where :2183"last_edited >= $::TIMESTAMP" : misc.minutes_ago(opts.age_m)2184"last_edited <= $::TIMESTAMP" : misc.minutes_ago(opts.min_age_m)2185cb : cb21862187get_stats_interval: (opts) =>2188opts = defaults opts,2189start : required2190end : required2191cb : required2192@_query2193query : 'SELECT * FROM stats'2194where :2195"time >= $::TIMESTAMP" : opts.start2196"time <= $::TIMESTAMP" : opts.end2197order_by : 'time'2198cb : all_results(opts.cb)21992200# If there is a cached version of stats (which has given ttl) return that -- this could have2201# been computed by any of the hubs. If there is no cached version, compute new one and store2202# in cache for ttl seconds.2203get_stats: (opts) =>2204opts = defaults opts,2205ttl_dt : 15 # 15 secs subtracted from ttl to compensate for computation duration when called via a cronjob2206ttl : 5*60 # how long cached version lives (in seconds)2207ttl_db : 30 # how long a valid result from a db query is cached in any case2208update : true # true: recalculate if older than ttl; false: don't recalculate and pick it from the DB (locally cached for ttl secs)2209cb : undefined2210return await calc_stats(@, opts)22112212get_active_student_stats: (opts) =>2213opts = defaults opts,2214cb : required2215dbg = @_dbg('get_active_student_stats')2216dbg()2217@_query2218query : "SELECT project_id, course, last_edited, settings, users FROM projects WHERE course IS NOT NULL AND last_edited >= $1"2219params : [misc.days_ago(30)]2220cb : all_results (err, t) =>2221if err2222opts.cb(err)2223return2224days14 = misc.days_ago(14)2225days7 = misc.days_ago(7)2226days1 = misc.days_ago(1)2227# student pay means that the student is required to pay2228num_student_pay = (x for x in t when x.course.pay).length2229# prof pay means that student isn't required to pay but2230# nonetheless project is on members only host2231num_prof_pay = 02232for x in t2233if not x.course.pay # student isn't paying2234if x.settings?.member_host2235num_prof_pay += 12236continue2237for _, d of x.users2238if d.upgrades?.member_host2239num_prof_pay += 12240continue2241# free - neither student pays, and also project not on members only server2242num_free = t.length - num_prof_pay - num_student_pay2243conversion_rate = if t.length then 100*(num_student_pay + num_prof_pay) / t.length else 02244data =2245conversion_rate : conversion_rate2246num_student_pay : num_student_pay2247num_prof_pay : num_prof_pay2248num_free : num_free2249num_1days : (x for x in t when x.last_edited >= days1).length2250num_7days : (x for x in t when x.last_edited >= days7).length2251num_14days : (x for x in t when x.last_edited >= days14).length2252num_30days : t.length2253opts.cb(undefined, data)225422552256###2257Hub servers2258###2259register_hub: (opts) =>2260opts = defaults opts,2261host : required2262port : required2263clients : required2264ttl : required2265cb : required2266# Since multiple hubs can run on the same host (but with different ports) and the host is the primary2267# key, we combine the host and port number in the host name for the db. The hub_servers table is only2268# used for tracking connection stats, so this is safe.2269@_query2270query : "INSERT INTO hub_servers"2271values :2272"host :: TEXT " : "#{opts.host}-#{opts.port}"2273"port :: INTEGER " : opts.port2274"clients :: INTEGER " : opts.clients2275"expire :: TIMESTAMP" : expire_time(opts.ttl)2276conflict : 'host'2277cb : opts.cb22782279get_hub_servers: (opts) =>2280opts = defaults opts,2281cb : required2282@_query2283query : "SELECT * FROM hub_servers"2284cb : all_results (err, v) =>2285if err2286opts.cb(err)2287return2288w = []2289to_delete = []2290now = new Date()2291for x in v2292if x.expire and x.expire <= now2293to_delete.push(x.host)2294else2295w.push(x)2296if to_delete.length > 02297@_query2298query : "DELETE FROM hub_servers"2299where : "host = ANY($)" : to_delete2300cb : (err) => opts.cb(err, w)2301else2302opts.cb(undefined, w)23032304###2305Custom software images2306###23072308# this is 100% for cc-in-cc dev projects only!2309insert_random_compute_images: (opts) =>2310opts = defaults opts,2311cb : required23122313dbg = @_dbg("database::insert_random_compute_images")2314dbg()23152316capitalize = require('@cocalc/util/misc').capitalize23172318words = [2319'wizard', 'jupyter', 'carrot', 'python', 'science', 'gold', 'eagle',2320'advanced', 'course', 'yellow', 'bioinformatics', 'R', 'electric', 'sheep',2321'theory', 'math', 'physics', 'calculate', 'primer', 'DNA', 'tech', 'space'2322]23232324# deterministically sample distinct words (such that this is stable after a restart)2325sample = (idx=0, n=1) ->2326N = words.length2327K = (idx * 997) %% N2328ret = []2329for i in [0..n]2330for j in [0..N]2331w = words[(K + 97 * i + j) %% N]2332if ret.includes(w)2333continue2334else2335ret.push(w)2336break2337return ret23382339rseed = 1232340random = ->2341x = Math.sin(rseed++)2342r = x - Math.floor(x)2343return r23442345create = (idx, cb) =>2346rnd = sample(idx, 3)2347id = rnd[...2].join('-') + "-#{idx}"2348provider = ['github.com', 'gitlab.com', 'bitbucket.org'][idx % 3]2349src = "https://#{provider}/#{rnd[2]}/#{id}.git"23502351# not all of them have a display-title, url, desc, ...2352if random() > .252353if random() > .52354extra = "(#{sample(idx + 2)})"2355else2356extra = sample(idx+5, 2)2357disp = (capitalize(_) for _ in rnd[...2].concat(extra)).join(' ')2358else2359if random() > .52360disp = undefined2361else2362disp = ''23632364if random() > .52365url = "https://www.google.com/search?q=#{rnd.join('%20')}"2366else2367url = undefined23682369if random() > .52370if random() > .52371verylong = Array(100).fill('very long *text* for **testing**, ').join(" ")2372if url?2373other_page = ", or point to [yet another page](#{url})"2374else2375other_page = ""2376desc = """2377This is some text describing what **#{disp or id}** is.2378Here could also be an [external link](https://doc.cocalc.com).2379It might also mention `#{id}`#{other_page}.23802381#{verylong ? ''}2382"""2383else2384desc = undefined23852386path = if random() > .5 then "index.ipynb" else "subdir/"2387tag = if random() > .25 then "master" else null238823892390@_query2391query : "INSERT INTO compute_images"2392values :2393"id :: TEXT " : id2394"src :: TEXT " : src2395"type :: TEXT " : 'custom'2396"desc :: TEXT " : desc2397"display :: TEXT " : disp2398"path :: TEXT " : path2399"url :: TEXT " : url2400"disabled:: BOOLEAN " : idx == 12401cb : cb24022403# first we wipe the table's content, then we generate some random stuff2404async.series([2405(cb) =>2406@_query2407query : 'DELETE FROM compute_images'2408where : '1 = 1'2409cb : cb24102411(cb) =>2412async.mapSeries([0..20], create, cb)24132414], (err) =>2415dbg("all done")2416opts.cb()2417)2418241924202421# Delete all patches, the blobs if archived, and the syncstring object itself2422# Basically this erases everything from cocalc related to the file edit history2423# of a given file... except ZFS snapshots.2424delete_syncstring: (opts) =>2425opts = defaults opts,2426string_id : required2427cb : required2428if not opts.string_id or misc.len(opts.string_id) != 402429# be extra careful!2430opts.cb("invalid string_id")2431return24322433locals =2434syncstring : undefined2435where : {"string_id = $::CHAR(40)" : opts.string_id}24362437async.series([2438(cb) =>2439@_query2440query : "SELECT * FROM syncstrings"2441where : locals.where2442cb : (err, results) =>2443if err2444cb(err)2445return2446locals.syncstring = results.rows[0]2447cb()2448(cb) =>2449if not locals.syncstring?2450# no syncstring with this id.2451cb(); return2452# delete the syncstring record (we do this first before deleting what if references,2453# since having a syncstring record referencing missing data would be a disaster, meaning2454# the user could never open their file -- with this sequence it just means some wasted2455# disks pace).2456@_query2457query : "DELETE FROM syncstrings"2458where : locals.where2459cb : cb2460(cb) =>2461if not locals.syncstring?2462# no syncstring with this id.2463cb(); return2464if locals.syncstring.archived2465# is archived, so delete the blob2466@delete_blob2467uuid : locals.syncstring.archived2468cb : cb2469else2470# is not archived, so delete the patches2471@_query2472query : "DELETE FROM patches"2473where : locals.where2474timeout_s: 3002475cb : cb2476], opts.cb)24772478syncdoc_history: (opts) =>2479opts = defaults opts,2480string_id : required2481patches : false # if true, include actual patches2482cb : required2483try2484opts.cb(undefined, await syncdoc_history(@, opts.string_id, opts.patches))2485catch err2486opts.cb(err)24872488syncdoc_history_async : (string_id, patches) =>2489return await syncdoc_history(@, string_id, patches)24902491# async function2492site_license_usage_stats: () =>2493return await site_license_usage_stats(@)24942495# async function2496projects_using_site_license: (opts) =>2497return await projects_using_site_license(@, opts)24982499# async function2500number_of_projects_using_site_license: (opts) =>2501return await number_of_projects_using_site_license(@, opts)25022503# async function2504site_license_public_info: (license_id) =>2505return await site_license_public_info(@, license_id)25062507# async function2508site_license_manager_set: (license_id, info) =>2509return await site_license_manager_set(@, license_id, info)25102511# async function2512update_site_license_usage_log: =>2513return await update_site_license_usage_log(@)25142515# async function2516matching_site_licenses: (...args) =>2517return await matching_site_licenses(@, ...args)25182519# async function2520manager_site_licenses: (...args) =>2521return await manager_site_licenses(@, ...args)25222523# async function2524project_datastore_set: (...args) =>2525return await project_datastore_set(@, ...args)25262527# async function2528project_datastore_get: (...args) =>2529return await project_datastore_get(@, ...args)25302531# async function2532project_datastore_del: (...args) =>2533return await project_datastore_del(@, ...args)25342535# async function2536permanently_unlink_all_deleted_projects_of_user: (account_id_or_email_address) =>2537return await permanently_unlink_all_deleted_projects_of_user(@, account_id_or_email_address)25382539# async function2540unlink_old_deleted_projects: () =>2541return await unlink_old_deleted_projects(@)25422543# async function2544unlist_all_public_paths: (account_id, is_owner) =>2545return await unlist_all_public_paths(@, account_id, is_owner)25462547# async2548projects_that_need_to_be_started: () =>2549return await projects_that_need_to_be_started(@)25502551# async2552# this *merges* in the run_quota; it doesn't replace it.2553set_run_quota: (project_id, run_quota) =>2554return await @async_query2555query : "UPDATE projects"2556jsonb_merge : {run_quota:run_quota}2557where : {project_id:project_id}25582559# async -- true if they are a manager on a license or have2560# any subscriptions.2561is_paying_customer: (account_id) =>2562return await is_paying_customer(@, account_id)25632564# async2565get_all_public_paths: (account_id) =>2566return await get_all_public_paths(@, account_id)25672568# async2569# Return true if the given account is a member or2570# owner of the given organization.2571accountIsInOrganization: (opts) =>2572result = await @async_query2573query : 'SELECT COUNT(*) FROM organizations'2574cache : true2575where : ['organization_id :: UUID = $1', "users ? $2"]2576params: [opts.organization_id, opts.account_id]2577return parseInt(result?.rows?[0]?.count) > 025782579# given a name, returns undefined if it is not in use,2580# and the account_id or organization_id that is using it2581# if it is in use.2582nameToAccountOrOrganization: (name) =>2583name = name.toLowerCase()2584result = await @async_query2585query : 'SELECT account_id FROM accounts'2586cache : false2587where : ['LOWER(name) = $1']2588params: [name]2589if result.rows.length > 02590return result.rows[0].account_id2591result = await @async_query2592query : 'SELECT organization_id FROM organizations'2593cache : false2594where : ['LOWER(name) = $1']2595params: [name]2596if result.rows.length > 02597return result.rows[0].organization_id2598return undefined25992600# async2601registrationTokens: (options, query) =>2602return await registrationTokens(@, options, query)260326042605