UNPKG

smc-hub

Version:

CoCalc: Backend webserver component

1,197 lines (1,093 loc) 132 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('smc-util-node/misc_node') misc2_node = require('smc-util-node/misc') {defaults} = misc = require('smc-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('smc-util/schema') { DEFAULT_COMPUTE_IMAGE } = require("smc-util/compute-images") PROJECT_GROUPS = misc.PROJECT_GROUPS {PROJECT_COLUMNS, one_result, all_results, count_result, expire_time} = require('./postgres-base') {syncdoc_history} = require('./postgres/syncdoc-history') collab = require('./postgres/collab') {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') {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') {projects_that_need_to_be_started} = require('./postgres/always-running'); {calc_stats} = require('./postgres/stats') SERVER_SETTINGS_EXTRAS = require("smc-util/db-schema/site-settings-extras").EXTRAS SITE_SETTINGS_CONF = require("smc-util/schema").site_settings_conf LRU = require('lru-cache'); SERVER_SETTINGS_CACHE = new LRU({ max: 50, maxAge: 60 * 1000 }) {pii_expire} = require("./utils") webapp_config_clear_cache = require("./webapp-configuration").clear_cache {stripe_name} = require('./stripe/client') # 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 cb : required async.series([ (cb) -> @_query query : 'INSERT INTO server_settings' values : 'name::TEXT' : opts.name 'value::TEXT' : opts.value 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: => SERVER_SETTINGS_CACHE.reset() webapp_config_clear_cache() 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 settings = SERVER_SETTINGS_CACHE.get('server_settings') if settings opts.cb(undefined, settings) return dbg = @_dbg('get_server_settings_cached') @_query query : 'SELECT name, value FROM server_settings' cache : true cb : (err, result) => if err opts.cb(err) else x = {_timestamp: Date.now()} # process values, possibly post-process values for k in result.rows val = k.value config = SITE_SETTINGS_CONF[k.name] ? SERVER_SETTINGS_EXTRAS[k.name] if config?.to_val? val = config.to_val(val) x[k.name] = val # set default values for missing keys for config in [SERVER_SETTINGS_EXTRAS, SITE_SETTINGS_CONF] for ckey in Object.keys(config) if (not x[ckey]?) or x[ckey] == '' conf = config[ckey] if conf?.to_val? x[ckey] = conf.to_val(conf.default) else x[ckey] = conf.default SERVER_SETTINGS_CACHE.set('server_settings', x) #dbg("server_settings = #{JSON.stringify(x, null, 2)}") opts.cb(undefined, x) 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 cb : required @_query query : 'INSERT into passport_settings' values : 'strategy::TEXT ' : opts.strategy 'conf ::JSONB' : opts.conf conflict : 'strategy' cb : opts.cb get_passport_settings: (opts) => opts = defaults opts, strategy : required cb : required @_query query : 'SELECT conf FROM passport_settings' where : "strategy = $::TEXT" : opts.strategy cb : one_result('conf', opts.cb) get_all_passport_settings: (opts) => opts = defaults opts, cb : required @_query query : 'SELECT strategy, conf FROM passport_settings' cb : all_results(opts.cb) get_all_passport_settings_cached: (opts) => opts = defaults opts, cb : required passports = SERVER_SETTINGS_CACHE.get('passports') if passports opts.cb(undefined, passports) return @get_all_passport_settings cb: (err, res) => if err opts.cb(err) else SERVER_SETTINGS_CACHE.set('passports', res) opts.cb(undefined, res) ### API Key Management ### get_api_key: (opts) => opts = defaults opts, account_id : required cb : required @_query query : 'SELECT api_key FROM accounts' where : "account_id = $::UUID" : opts.account_id cb : one_result (err, x) => opts.cb(err, x?.api_key ? '') get_account_with_api_key: (opts) => opts = defaults opts, api_key : required cb : required # cb(err, account_id) @_query query : 'SELECT account_id FROM accounts' where : "api_key = $::TEXT" : opts.api_key cb : one_result (err, x) => opts.cb(err, x?.account_id) delete_api_key: (opts) => opts = defaults opts, account_id : required cb : required @_query query : 'UPDATE accounts SET api_key = NULL' where : "account_id = $::UUID" : opts.account_id cb : opts.cb regenerate_api_key: (opts) => opts = defaults opts, account_id : required cb : required api_key = 'sk_' + random_key.generate(24) @_query query : 'UPDATE accounts' set : {api_key : api_key} where : "account_id = $::UUID" : opts.account_id cb : (err) => opts.cb(err, api_key) ### 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 touble! @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. 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) => 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 the 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) => 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 a the email address and verified email address 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) => 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 = require("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)) ### Auxillary 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) # This searches for users. In case someone has to debug this, the "clear text" for the user search by name (tokens) is # SELECT account_id, first_name, last_name, last_active, created # FROM accounts # WHERE deleted IS NOT TRUE # AND ( # ( # ( # lower(first_name) LIKE $1::TEXT # OR # lower(last_name) LIKE $1::TEXT # ) # AND # ( # lower(first_name) LIKE $2::TEXT # OR # lower(last_name) LIKE $2::TEXT # ) # AND # ... # ) # ) # AND ( # (last_active >= NOW() - $3::INTERVAL) # OR # (created >= NOW() - $3::INTERVAL) # ) # ORDER BY last_active DESC NULLS LAST # LIMIT $4::INTEGER user_search: (opts) => opts = defaults opts, query : required # comma separated list of email addresses or strings such as 'foo bar' (find everything where foo and bar are in the name) limit : 50 # limit on string queries; email query always returns 0 or 1 result per email address active : undefined # for name search (not email), only return users active this recently. -- disabled b/c of #2991 admin : false cb : required # cb(err, list of {id:?, first_name:?, last_name:?, email_address:?}), where the # email_address *only* occurs in search queries that are by email_address -- we do not reveal # email addresses of users queried by name. if opts.admin and (misc.is_valid_uuid_string(opts.query) or misc.is_valid_email_address(opts.query)) # One special case: when the query is just an email address or uuid, in which case we # just return that account (this is ONLY for admins) since # this includes the email address, except NOT an error if # there is no match @get_account account_id : if misc.is_valid_uuid_string(opts.query) then opts.query email_address : if misc.is_valid_email_address(opts.query) then opts.query columns:['account_id', 'first_name', 'last_name', 'email_address', 'last_active', 'created', 'banned'] cb: (err, account) => if err opts.cb(undefined, []) else opts.cb(undefined, [account]) return {string_queries, email_queries} = misc.parse_user_search(opts.query) if opts.admin # For admin we just do substring queries: for x in email_queries string_queries.push([x]) email_queries = [] dbg = @_dbg("user_search") dbg("query = #{misc.to_json(opts.query)}") #dbg("string_queries=#{misc.to_json(string_queries)}") #dbg("email_queries=#{misc.to_json(email_queries)}") locals = results : [] process = (rows) -> if not rows? return locals.results.push(rows...) async.parallel([ (cb) => if email_queries.length == 0 cb(); return dbg("do email queries