smc-hub
Version:
CoCalc: Backend webserver component
1,197 lines (1,093 loc) • 132 kB
text/coffeescript
#########################################################################
# 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