UNPKG

@cocalc/database

Version:

CoCalc: code for working with our PostgreSQL database

1,184 lines (1,076 loc) 121 kB
######################################################################### # This file is part of CoCalc: Copyright © 2020 Sagemath, Inc. # License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details ######################################################################### ### PostgreSQL -- implementation of all the queries needed for the backend servers These are all the non-reactive non-push queries, e.g., adding entries to logs, checking on cookies, creating accounts and projects, etc. COPYRIGHT : (c) 2017 SageMath, Inc. LICENSE : AGPLv3 ### # limit for async.map or async.paralleLimit, esp. to avoid high concurrency when querying in parallel MAP_LIMIT = 5 async = require('async') random_key = require("random-key") misc_node = require('@cocalc/backend/misc_node') misc2_node = require('@cocalc/backend/misc') {defaults} = misc = require('@cocalc/util/misc') required = defaults.required # IDK why, but if that import line is down below, where the other "./postgres/*" imports are, building manage # fails with: remember-me.ts(15,31): error TS2307: Cannot find module 'async-await-utils/hof' or its corresponding type declarations. {get_remember_me} = require('./postgres/remember-me') {SCHEMA, DEFAULT_QUOTAS, PROJECT_UPGRADES, COMPUTE_STATES, RECENT_TIMES, RECENT_TIMES_KEY, site_settings_conf} = require('@cocalc/util/schema') { quota } = require("@cocalc/util/upgrades/quota") { DEFAULT_COMPUTE_IMAGE } = require("@cocalc/util/db-schema/defaults") PROJECT_GROUPS = misc.PROJECT_GROUPS read = require('read') {PROJECT_COLUMNS, one_result, all_results, count_result, expire_time} = require('./postgres-base') {syncdoc_history} = require('./postgres/syncdoc-history') collab = require('./postgres/collab') # TODO is set_account_info_if_possible used here?! {is_paying_customer, set_account_info_if_possible} = require('./postgres/account-queries') {site_license_usage_stats, projects_using_site_license, number_of_projects_using_site_license} = require('./postgres/site-license/analytics') {update_site_license_usage_log} = require('./postgres/site-license/usage-log') {site_license_public_info} = require('./postgres/site-license/public') {site_license_manager_set} = require('./postgres/site-license/manager') {matching_site_licenses, manager_site_licenses} = require('./postgres/site-license/search') {sync_site_license_subscriptions} = require('./postgres/site-license/sync-subscriptions') {add_license_to_project, remove_license_from_project} = require('./postgres/site-license/add-remove') {project_datastore_set, project_datastore_get, project_datastore_del} = require('./postgres/project-queries') {checkEmailExclusiveSSO} = require("./postgres/check-email-exclusive-sso") {permanently_unlink_all_deleted_projects_of_user, unlink_old_deleted_projects} = require('./postgres/delete-projects') {get_all_public_paths, unlist_all_public_paths} = require('./postgres/public-paths') {get_personal_user} = require('./postgres/personal') {set_passport_settings, get_passport_settings, get_all_passport_settings, get_all_passport_settings_cached, create_passport, delete_passport, passport_exists, update_account_and_passport, _passport_key} = require('./postgres/passport') {projects_that_need_to_be_started} = require('./postgres/always-running'); {calc_stats} = require('./postgres/stats') {getServerSettings, resetServerSettingsCache, getPassportsCached, setPassportsCached} = require('@cocalc/server/settings/server-settings'); {pii_expire} = require("./postgres/pii") passwordHash = require("@cocalc/backend/auth/password-hash").default; registrationTokens = require('./postgres/registration-tokens').default; stripe_name = require('@cocalc/util/stripe/name').default; create_project = require("@cocalc/server/projects/create").default; {Stripe} = require("@cocalc/server/stripe/client") user_search = require("@cocalc/server/accounts/search").default; # log events, which contain personal information (email, account_id, ...) PII_EVENTS = ['create_account', 'change_password', 'change_email_address', 'webapp-add_passport', 'get_user_auth_token', 'successful_sign_in', 'webapp-email_sign_up', 'create_account_registration_token' ] exports.extend_PostgreSQL = (ext) -> class PostgreSQL extends ext # write an event to the central_log table log: (opts) => opts = defaults opts, event : required # string value : required # object cb : undefined expire = null if opts.event == 'uncaught_exception' expire = misc.expire_time(30 * 24 * 60 * 60) # del in 30 days else v = opts.value if v.ip_address? or v.email_address? or opts.event in PII_EVENTS expire = await pii_expire(@) @_query query : 'INSERT INTO central_log' values : 'id::UUID' : misc.uuid() 'event::TEXT' : opts.event 'value::JSONB' : opts.value 'time::TIMESTAMP' : 'NOW()' 'expire::TIMESTAMP' : expire cb : (err) => opts.cb?(err) uncaught_exception: (err) => # call when things go to hell in some unexpected way; at least # we attempt to record this in the database... try @log event : 'uncaught_exception' value : {error:"#{err}", stack:"#{err.stack}", host:require('os').hostname()} catch e # IT IS CRITICAL THAT uncaught_exception not raise an exception, since if it # did then we would hit a horrible infinite loop! # dump a range of data from the central_log table get_log: (opts) => opts = defaults opts, start : undefined # if not given start at beginning of time end : undefined # if not given include everything until now log : 'central_log' # which table to query event : undefined where : undefined # if given, restrict to records with the given json # containment, e.g., {account_id:'...'}, only returns # entries whose value has the given account_id. cb : required @_query query : "SELECT * FROM #{opts.log}" where : 'time >= $::TIMESTAMP' : opts.start 'time <= $::TIMESTAMP' : opts.end 'event = $::TEXT' : opts.event 'value @> $::JSONB' : opts.where cb : all_results(opts.cb) # Return every entry x in central_log in the given period of time for # which x.event==event and x.value.account_id == account_id. get_user_log: (opts) => opts = defaults opts, start : undefined end : undefined # if not given include everything until now event : 'successful_sign_in' account_id : required cb : required @get_log start : opts.start end : opts.end event : opts.event where : {account_id: opts.account_id} cb : opts.cb log_client_error: (opts) => opts = defaults opts, event : 'event' error : 'error' account_id : undefined cb : undefined # get rid of the entry in 30 days expire = misc.expire_time(30 * 24 * 60 * 60) @_query query : 'INSERT INTO client_error_log' values : 'id :: UUID' : misc.uuid() 'event :: TEXT' : opts.event 'error :: TEXT' : opts.error 'account_id :: UUID' : opts.account_id 'time :: TIMESTAMP' : 'NOW()' 'expire :: TIMESTAMP' : expire cb : opts.cb webapp_error: (opts) => opts = defaults opts, account_id : undefined name : undefined message : undefined comment : undefined stacktrace : undefined file : undefined path : undefined lineNumber : undefined columnNumber : undefined severity : undefined browser : undefined mobile : undefined responsive : undefined user_agent : undefined smc_version : undefined build_date : undefined smc_git_rev : undefined uptime : undefined start_time : undefined id : undefined # ignored cb : undefined # get rid of the entry in 30 days expire = misc.expire_time(30 * 24 * 60 * 60) @_query query : 'INSERT INTO webapp_errors' values : 'id :: UUID' : misc.uuid() 'account_id :: UUID' : opts.account_id 'name :: TEXT' : opts.name 'message :: TEXT' : opts.message 'comment :: TEXT' : opts.comment 'stacktrace :: TEXT' : opts.stacktrace 'file :: TEXT' : opts.file 'path :: TEXT' : opts.path 'lineNumber :: INTEGER' : opts.lineNumber 'columnNumber :: INTEGER' : opts.columnNumber 'severity :: TEXT' : opts.severity 'browser :: TEXT' : opts.browser 'mobile :: BOOLEAN' : opts.mobile 'responsive :: BOOLEAN' : opts.responsive 'user_agent :: TEXT' : opts.user_agent 'smc_version :: TEXT' : opts.smc_version 'build_date :: TEXT' : opts.build_date 'smc_git_rev :: TEXT' : opts.smc_git_rev 'uptime :: TEXT' : opts.uptime 'start_time :: TIMESTAMP' : opts.start_time 'time :: TIMESTAMP' : 'NOW()' 'expire :: TIMESTAMP' : expire cb : opts.cb get_client_error_log: (opts) => opts = defaults opts, start : undefined # if not given start at beginning of time end : undefined # if not given include everything until now event : undefined cb : required opts.log = 'client_error_log' @get_log(opts) set_server_setting: (opts) => opts = defaults opts, name : required value : required readonly : undefined # boolean. if yes, that value is not controlled via any UI cb : required async.series([ (cb) => values = 'name::TEXT' : opts.name 'value::TEXT' : opts.value if opts.readonly? values.readonly = !!opts.readonly @_query query : 'INSERT INTO server_settings' values : values conflict : 'name' cb : cb # also set a timestamp (cb) => @_query query : 'INSERT INTO server_settings' values : 'name::TEXT' : '_last_update' 'value::TEXT' : (new Date()).toISOString() conflict : 'name' cb : cb ], (err) => # clear the cache no matter what (e.g., server_settings might have partly changed then errored) @reset_server_settings_cache() opts.cb(err) ) reset_server_settings_cache: => resetServerSettingsCache() get_server_setting: (opts) => opts = defaults opts, name : required cb : required @_query query : 'SELECT value FROM server_settings' where : "name = $::TEXT" : opts.name cb : one_result('value', opts.cb) get_server_settings_cached: (opts) => opts = defaults opts, cb: required try opts.cb(undefined, await getServerSettings()) catch err opts.cb(err) get_site_settings: (opts) => opts = defaults opts, cb : required # (err, settings) @_query query : 'SELECT name, value FROM server_settings' cache : true where : "name = ANY($)" : misc.keys(site_settings_conf) cb : (err, result) => if err opts.cb(err) else x = {} for k in result.rows if k.name == 'commercial' and k.value in ['true', 'false'] # backward compatibility k.value = eval(k.value) x[k.name] = k.value opts.cb(undefined, x) server_settings_synctable: (opts={}) => opts.table = 'server_settings' return @synctable(opts) set_passport_settings: (opts) => opts = defaults opts, strategy : required conf : required info : undefined cb : required return await set_passport_settings(@, opts) get_passport_settings: (opts) => opts = defaults opts, strategy : required return await get_passport_settings(@, opts) get_all_passport_settings: () => return await get_all_passport_settings(@) get_all_passport_settings_cached: () => return await get_all_passport_settings_cached(@) create_passport: (opts) => return await create_passport(@, opts) delete_passport: (opts) => return delete_passport(@, opts) passport_exists: (opts) => return await passport_exists(@, opts) update_account_and_passport: (opts) => return await update_account_and_passport(@, opts) ### Account creation, deletion, existence ### create_account: (opts={}) => opts = defaults opts, first_name : undefined last_name : undefined created_by : undefined # ip address of computer creating this account email_address : undefined password_hash : undefined lti_id : undefined # 2-tuple <string[]>[iss, user_id] passport_strategy : undefined passport_id : undefined passport_profile : undefined usage_intent : undefined cb : required # cb(err, account_id) dbg = @_dbg("create_account(#{opts.first_name}, #{opts.last_name}, #{opts.lti_id}, #{opts.email_address}, #{opts.passport_strategy}, #{opts.passport_id}), #{opts.usage_intent}") dbg() for name in ['first_name', 'last_name'] if opts[name] test = misc2_node.is_valid_username(opts[name]) if test? opts.cb("#{name} not valid: #{test}") return if opts.email_address # canonicalize the email address, if given opts.email_address = misc.lower_email_address(opts.email_address) account_id = misc.uuid() passport_key = undefined if opts.passport_strategy? # This is to make it impossible to accidentally create two accounts with the same passport # due to calling create_account twice at once. See TODO below about changing schema. # This should be enough for now since a given user only makes their account through a single # server via the persistent websocket... @_create_account_passport_keys ?= {} passport_key = _passport_key(strategy:opts.passport_strategy, id:opts.passport_id) last = @_create_account_passport_keys[passport_key] if last? and new Date() - last <= 60*1000 opts.cb("recent attempt to make account with this passport strategy") return @_create_account_passport_keys[passport_key] = new Date() async.series([ (cb) => if not opts.passport_strategy? cb(); return dbg("verify that no account with passport (strategy='#{opts.passport_strategy}', id='#{opts.passport_id}') already exists") # **TODO:** need to make it so insertion into the table still would yield an error due to # unique constraint; this will require probably moving the passports # object to a separate table. This is important, since this is exactly the place where # a race condition might cause trouble! @passport_exists strategy : opts.passport_strategy id : opts.passport_id cb : (err, account_id) -> if err cb(err) else if account_id cb("account with email passport strategy '#{opts.passport_strategy}' and id '#{opts.passport_id}' already exists") else cb() (cb) => dbg("create the actual account") @_query query : "INSERT INTO accounts" values : 'account_id :: UUID' : account_id 'first_name :: TEXT' : opts.first_name 'last_name :: TEXT' : opts.last_name 'lti_id :: TEXT[]' : opts.lti_id 'created :: TIMESTAMP' : new Date() 'created_by :: INET' : opts.created_by 'password_hash :: CHAR(173)' : opts.password_hash 'email_address :: TEXT' : opts.email_address 'sign_up_usage_intent :: TEXT': opts.usage_intent cb : cb (cb) => if opts.passport_strategy? dbg("add passport authentication strategy") @create_passport account_id : account_id strategy : opts.passport_strategy id : opts.passport_id profile : opts.passport_profile cb : cb else cb() ], (err) => if err dbg("error creating account -- #{err}") opts.cb(err) else dbg("successfully created account") opts.cb(undefined, account_id) ) is_admin: (opts) => opts = defaults opts, account_id : required cb : required @_query query : "SELECT groups FROM accounts" where : 'account_id = $::UUID':opts.account_id cache : true cb : one_result 'groups', (err, groups) => opts.cb(err, groups? and 'admin' in groups) user_is_in_group: (opts) => opts = defaults opts, account_id : required group : required cb : required @_query query : "SELECT groups FROM accounts" where : 'account_id = $::UUID':opts.account_id cache : true cb : one_result 'groups', (err, groups) => opts.cb(err, groups? and opts.group in groups) make_user_admin: (opts) => opts = defaults opts, account_id : undefined email_address : undefined cb : required if not opts.account_id? and not opts.email_address? opts.cb?("account_id or email_address must be given") return async.series([ (cb) => if opts.account_id? cb() else @get_account email_address : opts.email_address columns : ['account_id'] cb : (err, x) => if err cb(err) else if not x? cb("no such email address") else opts.account_id = x.account_id cb() (cb) => @clear_cache() # caching is mostly for permissions so this is exactly when it would be nice to clear it. @_query query : "UPDATE accounts" where : 'account_id = $::UUID':opts.account_id set : groups : ['admin'] cb : cb ], opts.cb) count_accounts_created_by: (opts) => opts = defaults opts, ip_address : required age_s : required cb : required @_count table : 'accounts' where : "created_by = $::INET" : opts.ip_address "created >= $::TIMESTAMP" : misc.seconds_ago(opts.age_s) cb : opts.cb # Completely delete the given account from the database. This doesn't # do any sort of cleanup of things associated with the account! There # is no reason to ever use this, except for testing purposes. delete_account: (opts) => opts = defaults opts, account_id : required cb : required if not @_validate_opts(opts) then return @_query query : "DELETE FROM accounts" where : "account_id = $::UUID" : opts.account_id cb : opts.cb # Mark the account as deleted, thus freeing up the email # address for use by another account, etc. The actual # account entry remains in the database, since it may be # referred to by many other things (projects, logs, etc.). # However, the deleted field is set to true, so the account # is excluded from user search. # TODO: rewritten in packages/server/accounts/delete.ts mark_account_deleted: (opts) => opts = defaults opts, account_id : undefined email_address : undefined cb : required if not opts.account_id? and not opts.email_address? opts.cb("one of email address or account_id must be specified") return query = undefined email_address = undefined async.series([ (cb) => if opts.account_id? cb() else @account_exists email_address : opts.email_address cb : (err, account_id) => if err cb(err) else if not account_id cb("no such email address known") else opts.account_id = account_id cb() (cb) => @_query query : "SELECT email_address FROM accounts" where : "account_id = $::UUID" : opts.account_id cb : one_result 'email_address', (err, x) => email_address = x; cb(err) (cb) => @_query query : "UPDATE accounts" set : "deleted::BOOLEAN" : true "email_address_before_delete::TEXT" : email_address "email_address" : null "passports" : null where : "account_id = $::UUID" : opts.account_id cb : cb ], opts.cb) account_exists: (opts) => opts = defaults opts, email_address : required cb : required # cb(err, account_id or undefined) -- actual account_id if it exists; err = problem with db connection... @_query query : 'SELECT account_id FROM accounts' where : "email_address = $::TEXT" : opts.email_address cb : one_result('account_id', opts.cb) # set an account creation action, or return all of them for the given email address account_creation_actions: (opts) => opts = defaults opts, email_address : required action : undefined # if given, adds this action; if not, returns all non-expired actions ttl : 60*60*24*14 # add action with this ttl in seconds (default: 2 weeks) cb : required # if ttl not given cb(err, [array of actions]) if opts.action? # add action @_query query : 'INSERT INTO account_creation_actions' values : 'id :: UUID' : misc.uuid() 'email_address :: TEXT' : opts.email_address 'action :: JSONB' : opts.action 'expire :: TIMESTAMP' : expire_time(opts.ttl) cb : opts.cb else # query for actions @_query query : 'SELECT action FROM account_creation_actions' where : 'email_address = $::TEXT' : opts.email_address 'expire >= $::TIMESTAMP' : new Date() cb : all_results('action', opts.cb) account_creation_actions_success: (opts) => opts = defaults opts, account_id : required cb : required @_query query : 'UPDATE accounts' set : 'creation_actions_done::BOOLEAN' : true where : 'account_id = $::UUID' : opts.account_id cb : opts.cb do_account_creation_actions: (opts) => opts = defaults opts, email_address : required account_id : required cb : required dbg = @_dbg("do_account_creation_actions(email_address='#{opts.email_address}')") @account_creation_actions email_address : opts.email_address cb : (err, actions) => if err opts.cb(err); return f = (action, cb) => dbg("account_creation_actions: action = #{misc.to_json(action)}") if action.action == 'add_to_project' @add_user_to_project project_id : action.project_id account_id : opts.account_id group : action.group cb : (err) => if err dbg("Error adding user to project: #{err}") cb(err) else dbg("ERROR: skipping unknown action -- #{action.action}") # also store in database so we can look into this later. @log event : 'unknown_action' value : error : "unknown_action" action : action account_id : opts.account_id host : require('os').hostname() cb() async.map actions, f, (err) => if not err @account_creation_actions_success account_id : opts.account_id cb : opts.cb else opts.cb(err) verify_email_create_token: (opts) => # has been rewritten in backend/email/verify.ts opts = defaults opts, account_id : required cb : undefined locals = email_address : undefined token : undefined old_challenge : undefined async.series([ (cb) => @_query query : "SELECT email_address, email_address_challenge FROM accounts" where : "account_id = $::UUID" : opts.account_id cb : one_result (err, x) => locals.email_address = x?.email_address locals.old_challenge = x?.email_address_challenge cb(err) (cb) => # TODO maybe expire tokens after some time if locals.old_challenge? old = locals.old_challenge # return the same token if there is one for the same email if old.token? and old.email == locals.email_address locals.token = locals.old_challenge.token cb() return {generate} = require("random-key") locals.token = generate(16).toLowerCase() data = email : locals.email_address token : locals.token time : new Date() @_query query : "UPDATE accounts" set : 'email_address_challenge::JSONB' : data where : "account_id = $::UUID" : opts.account_id cb : cb ], (err) -> opts.cb?(err, locals) ) verify_email_check_token: (opts) => # rewritten in server/auth/redeem-verify-email.ts opts = defaults opts, email_address : required token : required cb : undefined locals = account_id : undefined email_address_challenge : undefined async.series([ (cb) => @get_account email_address : opts.email_address columns : ['account_id', 'email_address_challenge'] cb : (err, x) => if err cb(err) else if not x? cb("no such email address") else locals.account_id = x.account_id locals.email_address_challenge = x.email_address_challenge cb() (cb) => if not locals.email_address_challenge? @is_verified_email email_address : opts.email_address cb : (err, verified) -> if not err and verified cb("This email address is already verified.") else cb("For this email address no account verification is setup.") else if locals.email_address_challenge.email != opts.email_address cb("The account's email address does not match the token's email address.") else if locals.email_address_challenge.time < misc.hours_ago(24) cb("The account verification token is no longer valid. Get a new one!") else if locals.email_address_challenge.token == opts.token cb() else cb("Provided token does not match.") (cb) => # we're good, save it @_query query : "UPDATE accounts" jsonb_set : email_address_verified: "#{opts.email_address}" : new Date() where : "account_id = $::UUID" : locals.account_id cb : cb (cb) => # now delete the token @_query query : 'UPDATE accounts' set : 'email_address_challenge::JSONB' : null where : "account_id = $::UUID" : locals.account_id cb : cb ], opts.cb) # returns the email address and whether or not it is verified verify_email_get: (opts) => opts = defaults opts, account_id : required cb : undefined @_query query : "SELECT email_address, email_address_verified FROM accounts" where : "account_id = $::UUID" : opts.account_id cb : one_result (err, x) -> opts.cb?(err, x) # answers the question as cb(null, [true or false]) is_verified_email: (opts) => # rewritten in server/auth/redeem-verify-email.ts opts = defaults opts, email_address : required cb : required @get_account email_address : opts.email_address columns : ['email_address_verified'] cb : (err, x) => if err opts.cb(err) else if not x? opts.cb("no such email address") else verified = !!x.email_address_verified?[opts.email_address] opts.cb(undefined, verified) ### Stripe support for accounts ### # Set the stripe id in our database of this user. If there is no user with this # account_id, then this is a NO-OP. set_stripe_customer_id: (opts) => opts = defaults opts, account_id : required customer_id : required cb : required @_query query : 'UPDATE accounts' set : 'stripe_customer_id::TEXT' : opts.customer_id where : 'account_id = $::UUID' : opts.account_id cb : opts.cb # Get the stripe id in our database of this user (or undefined if not stripe_id or no such user). get_stripe_customer_id: (opts) => opts = defaults opts, account_id : required cb : required @_query query : 'SELECT stripe_customer_id FROM accounts' where : 'account_id = $::UUID' : opts.account_id cb : one_result('stripe_customer_id', opts.cb) ### Stripe Synchronization Get all info about the given account from stripe and put it in our own local database. Also call it right after the user does some action that will change their account info status. Additionally, it checks the email address Stripe knows about the customer and updates it if it changes. This will never touch stripe if the user doesn't have a stripe_customer_id. TODO: This should be replaced by webhooks... ### stripe_update_customer: (opts) => opts = defaults opts, account_id : required # user's account_id stripe : undefined # api connection to stripe customer_id : undefined # will be looked up if not known cb : undefined # cb(err, new stripe customer record) locals = customer : undefined email_address : undefined first_name: undefined last_name: undefined dbg = @_dbg("stripe_update_customer(account_id='#{opts.account_id}')") async.series([ (cb) => if opts.customer_id? cb(); return dbg("get_stripe_customer_id") @get_stripe_customer_id account_id : opts.account_id cb : (err, x) => dbg("their stripe id is #{x}") opts.customer_id = x; cb(err) (cb) => if opts.customer_id? and not opts.stripe? @get_server_setting name : 'stripe_secret_key' cb : (err, secret) => if err cb(err) else if not secret cb("stripe must be configured") else opts.stripe = Stripe(secret) cb() else cb() (cb) => if opts.customer_id? opts.stripe.customers.retrieve opts.customer_id, (err, x) => dbg("got stripe info -- #{err}") locals.customer = x; cb(err) else cb() # sync email (cb) => if not opts.customer_id? cb(); return @_query query : "SELECT email_address, first_name, last_name FROM accounts" where : "account_id = $::UUID" : opts.account_id cb : one_result (err, x) -> if err? cb(err) return else locals.email_address = x.email_address locals.first_name = x.first_name locals.last_name = x.last_name cb() (cb) => if not opts.customer_id? cb(); return if not misc.is_valid_email_address(locals.email_address) console.log("got invalid email address '#{locals.email_address}' to update stripe from '#{opts.account_id}'") cb(); return name = stripe_name(locals.first_name, locals.last_name) email_changed = locals.email_address != locals.customer.email name_undef = not locals.customer.name? name_changed = locals.customer.name != name or locals.customer.description != name if email_changed or name_undef or name_changed upd = email : locals.email_address name : name description : name # see stripe/client, we also set the description to the name! opts.stripe.customers.update opts.customer_id, upd, (err, x) => if err? cb(err) return if x.email != locals.email_address cb("stripe email address is still off: '#{locals.customer.email}'") return # all fine, updating our local customer object with the new email address locals.customer = x cb() else cb() # syncing email finished, now we update our record of what stripe knows (cb) => if not opts.customer_id? cb(); return @_query query : 'UPDATE accounts' set : 'stripe_customer::JSONB' : locals.customer where : 'account_id = $::UUID' : opts.account_id cb : cb ], (err) => opts.cb(err, locals.customer)) ### Auxiliary billing related queries ### get_coupon_history: (opts) => opts = defaults opts, account_id : required cb : undefined @_dbg("Getting coupon history") @_query query : "SELECT coupon_history FROM accounts" where : 'account_id = $::UUID' : opts.account_id cb : one_result("coupon_history", opts.cb) update_coupon_history: (opts) => opts = defaults opts, account_id : required coupon_history : required cb : undefined @_dbg("Setting to #{opts.coupon_history}") @_query query : 'UPDATE accounts' set : 'coupon_history::JSONB' : opts.coupon_history where : 'account_id = $::UUID' : opts.account_id cb : opts.cb ### Querying for searchable information about accounts. ### account_ids_to_usernames: (opts) => opts = defaults opts, account_ids : required cb : required # (err, mapping {account_id:{first_name:?, last_name:?}}) if not @_validate_opts(opts) then return if opts.account_ids.length == 0 # easy special case -- don't waste time on a db query opts.cb(undefined, []) return @_query query : 'SELECT account_id, first_name, last_name FROM accounts' where : 'account_id = ANY($::UUID[])' : opts.account_ids cb : (err, result) => if err opts.cb(err) else v = misc.dict(([r.account_id, {first_name:r.first_name, last_name:r.last_name}] for r in result.rows)) # fill in unknown users (should never be hit...) for id in opts.account_ids if not v[id]? v[id] = {first_name:undefined, last_name:undefined} opts.cb(err, v) get_usernames: (opts) => opts = defaults opts, account_ids : required use_cache : true cache_time_s : 60*60 # one hour cb : required # cb(err, map from account_id to object (user name)) if not @_validate_opts(opts) then return usernames = {} for account_id in opts.account_ids usernames[account_id] = false if opts.use_cache if not @_account_username_cache? @_account_username_cache = {} for account_id, done of usernames if not done and @_account_username_cache[account_id]? usernames[account_id] = @_account_username_cache[account_id] @account_ids_to_usernames account_ids : (account_id for account_id,done of usernames when not done) cb : (err, results) => if err opts.cb(err) else # use a closure so that the cache clear timeout below works # with the correct account_id! f = (account_id, username) => usernames[account_id] = username @_account_username_cache[account_id] = username setTimeout((()=>delete @_account_username_cache[account_id]), 1000*opts.cache_time_s) for account_id, username of results f(account_id, username) opts.cb(undefined, usernames) user_search: (opts) => try opts.cb(undefined, await user_search(opts)) catch err opts.cb(err.message) _account_where: (opts) => # account_id > email_address > lti_id if opts.account_id return {"account_id = $::UUID" : opts.account_id} else if opts.email_address return {"email_address = $::TEXT" : opts.email_address} else if opts.lti_id return {"lti_id = $::TEXT[]" : opts.lti_id} else throw Error("postgres-server-queries::_account_where neither account_id, nor email_address, nor lti_id specified and nontrivial") get_account: (opts) => opts = defaults opts, email_address : undefined # provide one of email, account_id, or lti_id (pref is account_id, then email_address, then lti_id) account_id : undefined lti_id : undefined columns : ['account_id', 'password_hash', 'password_is_set', # true or false, depending on whether a password is set (since don't send password_hash to user!) 'first_name', 'last_name', 'email_address', 'evaluate_key', 'autosave', 'terminal', 'editor_settings', 'other_settings', 'groups', 'passports' ] cb : required if not @_validate_opts(opts) then return columns = misc.copy(opts.columns) if 'password_is_set' in columns if 'password_hash' not in columns remove_password_hash = true columns.push('password_hash') misc.remove(columns, 'password_is_set') password_is_set = true @_query query : "SELECT #{columns.join(',')} FROM accounts" where : @_account_where(opts) cb : one_result (err, z) => if err opts.cb(err) else if not z? opts.cb("no such account") else if password_is_set z.password_is_set = !!z.password_hash if remove_password_hash delete z.password_hash for c in columns if not z[c]? # for same semantics as rethinkdb... (for now) delete z[c] opts.cb(undefined, z) # check whether or not a user is banned is_banned_user: (opts) => opts = defaults opts, email_address : undefined account_id : undefined cb : required # cb(err, true if banned; false if not banned) if not @_validate_opts(opts) then return @_query query : 'SELECT banned FROM accounts' where : @_account_where(opts) cb : one_result('banned', (err, banned) => opts.cb(err, !!banned)) _set_ban_user: (opts) => opts = defaults opts, account_id : undefined email_address : undefined banned : required cb : required if not @_validate_opts(opts) then return @_query query : 'UPDATE accounts' set : {banned: opts.banned} where : @_account_where(opts) cb : one_result('banned', opts.cb) ban_user: (opts) => @_set_ban_user(misc.merge(opts, banned:true)) unban_user: (opts) => @_set_ban_user(misc.merge(opts, banned:false)) _touch_account: (account_id, cb) => if @_throttle('_touch_account', 120, account_id) cb() return @_query query : 'UPDATE accounts' set : {last_active: 'NOW()'} where : "account_id = $::UUID" : account_id cb : cb _touch_project: (project_id, account_id, cb) => if @_throttle('_user_touch_project', 60, project_id, account_id) cb() return NOW = new Date() @_query query : "UPDATE projects" set : {last_edited : NOW} jsonb_merge : {last_active:{"#{account_id}":NOW}} where : "project_id = $::UUID" : project_id cb : cb # Indicate activity by a user, possibly on a specific project, and # then possibly on a specific path in that project. touch: (opts) => opts = defaults opts, account_id : required project_id : undefined path : undefined action : 'edit' ttl_s : 50 # min activity interval; calling this function with same input again within this interval is ignored cb : undefined if opts.ttl_s if @_throttle('touch', opts.ttl_s, opts.account_id, opts.project_id, opts.path, opts.action) opts.cb?() return now = new Date() async.parallel([ (cb) => @_touch_account(opts.account_id, cb) (cb) => if not opts.project_id? cb(); return @_touch_project(opts.project_id, opts.account_id, cb) (cb) => if not opts.path? or not opts.project_id? cb(); return @record_file_use(project_id:opts.project_id, path:opts.path, action:opts.action, account_id:opts.account_id, cb:cb) ], (err)->opts.cb?(err)) ### Rememberme coo