smc-hub
Version:
CoCalc: Backend webserver component
1,146 lines (1,076 loc) • 171 kB
JavaScript
// Generated by CoffeeScript 2.5.1
(function() {
//########################################################################
// This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
// License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details
//########################################################################
var COMPUTE_STATES, DEFAULT_COMPUTE_IMAGE, DEFAULT_QUOTAS, LRU, MAP_LIMIT, PII_EVENTS, PROJECT_COLUMNS, PROJECT_GROUPS, PROJECT_UPGRADES, RECENT_TIMES, RECENT_TIMES_KEY, SCHEMA, SERVER_SETTINGS_CACHE, SERVER_SETTINGS_EXTRAS, SITE_SETTINGS_CONF, add_license_to_project, all_results, async, calc_stats, collab, count_result, defaults, expire_time, get_all_public_paths, get_personal_user, get_remember_me, is_paying_customer, manager_site_licenses, matching_site_licenses, misc, misc2_node, misc_node, number_of_projects_using_site_license, one_result, permanently_unlink_all_deleted_projects_of_user, pii_expire, project_datastore_del, project_datastore_get, project_datastore_set, projects_that_need_to_be_started, projects_using_site_license, random_key, remove_license_from_project, required, set_account_info_if_possible, site_license_manager_set, site_license_public_info, site_license_usage_stats, site_settings_conf, stripe_name, sync_site_license_subscriptions, syncdoc_history, unlink_old_deleted_projects, unlist_all_public_paths, update_site_license_usage_log, webapp_config_clear_cache,
boundMethodCheck = function(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new Error('Bound instance method accessed before binding'); } },
indexOf = [].indexOf,
modulo = function(a, b) { return (+a % (b = +b) + b) % b; };
/*
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 = function(ext) {
var PostgreSQL;
return PostgreSQL = class PostgreSQL extends ext {
constructor() {
super(...arguments);
// write an event to the central_log table
this.log = this.log.bind(this);
this.uncaught_exception = this.uncaught_exception.bind(this);
// 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
this.get_log = this.get_log.bind(this);
// 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.
this.get_user_log = this.get_user_log.bind(this);
this.log_client_error = this.log_client_error.bind(this);
this.webapp_error = this.webapp_error.bind(this);
this.get_client_error_log = this.get_client_error_log.bind(this);
this.set_server_setting = this.set_server_setting.bind(this);
this.reset_server_settings_cache = this.reset_server_settings_cache.bind(this);
this.get_server_setting = this.get_server_setting.bind(this);
this.get_server_settings_cached = this.get_server_settings_cached.bind(this);
this.get_site_settings = this.get_site_settings.bind(this);
this.server_settings_synctable = this.server_settings_synctable.bind(this);
this.set_passport_settings = this.set_passport_settings.bind(this);
this.get_passport_settings = this.get_passport_settings.bind(this);
this.get_all_passport_settings = this.get_all_passport_settings.bind(this);
this.get_all_passport_settings_cached = this.get_all_passport_settings_cached.bind(this);
/*
API Key Management
*/
this.get_api_key = this.get_api_key.bind(this);
this.get_account_with_api_key = this.get_account_with_api_key.bind(this);
this.delete_api_key = this.delete_api_key.bind(this);
this.regenerate_api_key = this.regenerate_api_key.bind(this);
/*
Account creation, deletion, existence
*/
this.create_account = this.create_account.bind(this);
this.is_admin = this.is_admin.bind(this);
this.user_is_in_group = this.user_is_in_group.bind(this);
this.make_user_admin = this.make_user_admin.bind(this);
this.count_accounts_created_by = this.count_accounts_created_by.bind(this);
// 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.
this.delete_account = this.delete_account.bind(this);
// 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.
this.mark_account_deleted = this.mark_account_deleted.bind(this);
this.account_exists = this.account_exists.bind(this);
// set an account creation action, or return all of them for the given email address
this.account_creation_actions = this.account_creation_actions.bind(this);
this.account_creation_actions_success = this.account_creation_actions_success.bind(this);
this.do_account_creation_actions = this.do_account_creation_actions.bind(this);
this.verify_email_create_token = this.verify_email_create_token.bind(this);
this.verify_email_check_token = this.verify_email_check_token.bind(this);
// returns a the email address and verified email address
this.verify_email_get = this.verify_email_get.bind(this);
// answers the question as cb(null, [true or false])
this.is_verified_email = this.is_verified_email.bind(this);
/*
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.
this.set_stripe_customer_id = this.set_stripe_customer_id.bind(this);
// Get the stripe id in our database of this user (or undefined if not stripe_id or no such user).
this.get_stripe_customer_id = this.get_stripe_customer_id.bind(this);
/*
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...
*/
this.stripe_update_customer = this.stripe_update_customer.bind(this);
/*
Auxillary billing related queries
*/
this.get_coupon_history = this.get_coupon_history.bind(this);
this.update_coupon_history = this.update_coupon_history.bind(this);
/*
Querying for searchable information about accounts.
*/
this.account_ids_to_usernames = this.account_ids_to_usernames.bind(this);
this.get_usernames = this.get_usernames.bind(this);
// 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
this.user_search = this.user_search.bind(this);
this._account_where = this._account_where.bind(this);
this.get_account = this.get_account.bind(this);
// check whether or not a user is banned
this.is_banned_user = this.is_banned_user.bind(this);
this._set_ban_user = this._set_ban_user.bind(this);
this.ban_user = this.ban_user.bind(this);
this.unban_user = this.unban_user.bind(this);
/*
Passports -- accounts linked to Google/Dropbox/Facebook/Github, etc.
The Schema is slightly redundant, but indexed properly:
{passports:['google-id', 'facebook-id'], passport_profiles:{'google-id':'...', 'facebook-id':'...'}}
*/
this._passport_key = this._passport_key.bind(this);
this.create_passport = this.create_passport.bind(this);
this.delete_passport = this.delete_passport.bind(this);
this.passport_exists = this.passport_exists.bind(this);
this._touch_account = this._touch_account.bind(this);
this._touch_project = this._touch_project.bind(this);
// Indicate activity by a user, possibly on a specific project, and
// then possibly on a specific path in that project.
this.touch = this.touch.bind(this);
/*
Rememberme cookie functionality
*/
// Save remember me info in the database
this.save_remember_me = this.save_remember_me.bind(this);
// Invalidate all outstanding remember me cookies for the given account by
// deleting them from the remember_me key:value store.
this.invalidate_all_remember_me = this.invalidate_all_remember_me.bind(this);
// Get remember me cookie with given hash. If it has expired,
// get back undefined instead. (Actually deleting expired).
// We use retry_until_success, since an intermittent database
// reconnect can result in a cb error that will very soon
// work fine, and we don't to flat out sign the client out
// just because of this.
this.get_remember_me = this.get_remember_me.bind(this);
this.delete_remember_me = this.delete_remember_me.bind(this);
// ASYNC FUNCTION
this.get_personal_user = this.get_personal_user.bind(this);
/*
* Changing password/email, etc. sensitive info about a user
*/
// Change the password for the given account.
this.change_password = this.change_password.bind(this);
// Reset Password MEANT FOR INTERACTIVE USE -- if password is not given, will prompt for it.
this.reset_password = this.reset_password.bind(this);
// Change the email address, unless the email_address we're changing to is already taken.
// If there is a stripe customer ID, we also call the update process to maybe sync the changed email address
this.change_email_address = this.change_email_address.bind(this);
/*
User auth token
*/
// save an auth token in the database
this.save_auth_token = this.save_auth_token.bind(this);
// Get account_id of account with given auth_token. If it
// is not defined, get back undefined instead.
this.get_auth_token_account_id = this.get_auth_token_account_id.bind(this);
this.delete_auth_token = this.delete_auth_token.bind(this);
/*
Password reset
*/
this.set_password_reset = this.set_password_reset.bind(this);
this.get_password_reset = this.get_password_reset.bind(this);
this.delete_password_reset = this.delete_password_reset.bind(this);
this.record_password_reset_attempt = this.record_password_reset_attempt.bind(this);
this.count_password_reset_attempts = this.count_password_reset_attempts.bind(this);
/*
Tracking file access
log_file_access is throttled in each server, in the sense that
if it is called with the same input within a minute, those
subsequent calls are ignored. Of course, if multiple servers
are recording file_access then there can be more than one
entry per minute.
*/
this.log_file_access = this.log_file_access.bind(this);
/*
Efficiently get all files access times subject to various constraints...
NOTE: this was not available in RethinkDB version (too painful to implement!), but here it is,
easily sliceable in any way. This could be VERY useful for users!
*/
this.get_file_access = this.get_file_access.bind(this);
// Create a new project with given owner. Returns the generated project_id.
this.create_project = this.create_project.bind(this);
/*
File editing activity -- users modifying files in any way
- one single table called file_activity
- table also records info about whether or not activity has been seen by users
*/
this.record_file_use = this.record_file_use.bind(this);
this.get_file_use = this.get_file_use.bind(this);
this._validate_opts = this._validate_opts.bind(this);
this.get_project = this.get_project.bind(this);
this._get_project_column = this._get_project_column.bind(this);
this.get_user_column = this.get_user_column.bind(this);
this.add_user_to_project = this.add_user_to_project.bind(this);
this.set_project_status = this.set_project_status.bind(this);
this.set_compute_server_status = this.set_compute_server_status.bind(this);
// Remove the given collaborator from the project.
// Attempts to remove an *owner* via this function will silently fail (change their group first),
// as will attempts to remove a user not on the project, or to remove from a non-existent project.
this.remove_collaborator_from_project = this.remove_collaborator_from_project.bind(this);
// remove any user, even an owner.
this.remove_user_from_project = this.remove_user_from_project.bind(this);
// async
this.add_collaborators_to_projects = this.add_collaborators_to_projects.bind(this);
// Return a list of the account_id's of all collaborators of the given users.
this.get_collaborator_ids = this.get_collaborator_ids.bind(this);
// get list of project collaborator IDs
this.get_collaborators = this.get_collaborators.bind(this);
// return list of paths that are public and not disabled in the given project
this.get_public_paths = this.get_public_paths.bind(this);
this.has_public_path = this.has_public_path.bind(this);
this.path_is_public = this.path_is_public.bind(this);
this.filter_public_paths = this.filter_public_paths.bind(this);
// Set last_edited for this project to right now, and possibly update its size.
// It is safe and efficient to call this function very frequently since it will
// actually hit the database at most once every 30s (per project, per client). In particular,
// once called, it ignores subsequent calls for the same project for 30s.
this.touch_project = this.touch_project.bind(this);
this.recently_modified_projects = this.recently_modified_projects.bind(this);
this.get_open_unused_projects = this.get_open_unused_projects.bind(this);
// cb(err, true if user is in one of the groups for the project **or an admin**)
this.user_is_in_project_group = this.user_is_in_project_group.bind(this);
// cb(err, true if user is an actual collab; ADMINS do not count)
this.user_is_collaborator = this.user_is_collaborator.bind(this);
// all id's of projects having anything to do with the given account
this.get_project_ids_with_user = this.get_project_ids_with_user.bind(this);
// cb(err, array of account_id's of accounts in non-invited-only groups)
// TODO: add something about invited users too and show them in UI!
this.get_account_ids_using_project = this.get_account_ids_using_project.bind(this);
// Have we successfully (no error) sent an invite to the given email address?
// If so, returns timestamp of when.
// If not, returns 0.
this.when_sent_project_invite = this.when_sent_project_invite.bind(this);
// call this to record that we have sent an email invite to the given email address
this.sent_project_invite = this.sent_project_invite.bind(this);
/*
Project host, storage location, and state.
*/
this.set_project_host = this.set_project_host.bind(this);
this.unset_project_host = this.unset_project_host.bind(this);
this.get_project_host = this.get_project_host.bind(this);
this.set_project_storage = this.set_project_storage.bind(this);
this.get_project_storage = this.get_project_storage.bind(this);
this.update_project_storage_save = this.update_project_storage_save.bind(this);
this.set_project_storage_request = this.set_project_storage_request.bind(this);
this.get_project_storage_request = this.get_project_storage_request.bind(this);
this.set_project_state = this.set_project_state.bind(this);
this.get_project_state = this.get_project_state.bind(this);
/*
Project quotas and upgrades
*/
// Returns the total quotas for the project, including any
// upgrades to the base settings.
this.get_project_quotas = this.get_project_quotas.bind(this);
// Return mapping from project_id to map listing the upgrades this particular user
// applied to the given project. This only includes project_id's of projects that
// this user may have upgraded in some way.
this.get_user_project_upgrades = this.get_user_project_upgrades.bind(this);
// Ensure that all upgrades applied by the given user to projects are consistent,
// truncating any that exceed their allotment. NOTE: Unless there is a bug,
// the only way the quotas should ever exceed their allotment would be if the
// user is trying to cheat... *OR* a subscription was cancelled or ended.
this.ensure_user_project_upgrades_are_valid = this.ensure_user_project_upgrades_are_valid.bind(this);
// Loop through every user of cocalc that is connected with stripe (so may have a subscription),
// and ensure that any upgrades that have applied to projects are valid. It is important to
// run this periodically or there is a really natural common case where users can cheat:
// (1) they apply upgrades to a project
// (2) their subscription expires
// (3) they do NOT touch upgrades on any projects again.
this.ensure_all_user_project_upgrades_are_valid = this.ensure_all_user_project_upgrades_are_valid.bind(this);
// Ensure all (or just for given account_id) site license subscriptions
// are non-expired iff subscription in stripe is "active" or "trialing"
// account_id is optional; if not given iterates over all users
// with stripe_customer field set.
// async/await:
this.sync_site_license_subscriptions = this.sync_site_license_subscriptions.bind(this);
// Return the sum total of all user upgrades to a particular project
this.get_project_upgrades = this.get_project_upgrades.bind(this);
// Remove all upgrades to all projects applied by this particular user.
this.remove_all_user_project_upgrades = this.remove_all_user_project_upgrades.bind(this);
// TODO: any impacted project that is currently running should also (optionally?) get restarted.
// I'm not going to bother for now, but this DOES need to get implemented, since otherwise users
// can cheat too easily. Alternatively, have a periodic control loop on all running projects that
// confirms that everything is legit (and remove the verification code for user_query) --
// that's probably better. This could be a service called manage-upgrades.
/*
Project settings
*/
this.get_project_settings = this.get_project_settings.bind(this);
this.set_project_settings = this.set_project_settings.bind(this);
this.get_project_extra_env = this.get_project_extra_env.bind(this);
this.recent_projects = this.recent_projects.bind(this);
this.get_stats_interval = this.get_stats_interval.bind(this);
// If there is a cached version of stats (which has given ttl) return that -- this could have
// been computed by any of the hubs. If there is no cached version, compute new one and store
// in cache for ttl seconds.
this.get_stats = this.get_stats.bind(this);
this.get_active_student_stats = this.get_active_student_stats.bind(this);
/*
Hub servers
*/
this.register_hub = this.register_hub.bind(this);
this.get_hub_servers = this.get_hub_servers.bind(this);
/*
Custom software images
*/
// this is 100% for cc-in-cc dev projects only!
this.insert_random_compute_images = this.insert_random_compute_images.bind(this);
/*
Compute servers
*/
this.save_compute_server = this.save_compute_server.bind(this);
this.get_compute_server = this.get_compute_server.bind(this);
this.get_all_compute_servers = this.get_all_compute_servers.bind(this);
this.get_projects_on_compute_server = this.get_projects_on_compute_server.bind(this);
this.is_member_host_compute_server = this.is_member_host_compute_server.bind(this);
// Delete all patches, the blobs if archived, and the syncstring object itself
// Basically this erases everything from cocalc related to the file edit history
// of a given file... except ZFS snapshots.
this.delete_syncstring = this.delete_syncstring.bind(this);
this.syncdoc_history = this.syncdoc_history.bind(this);
this.syncdoc_history_async = this.syncdoc_history_async.bind(this);
// async function
this.site_license_usage_stats = this.site_license_usage_stats.bind(this);
// async function
this.projects_using_site_license = this.projects_using_site_license.bind(this);
// async function
this.number_of_projects_using_site_license = this.number_of_projects_using_site_license.bind(this);
// async function
this.site_license_public_info = this.site_license_public_info.bind(this);
// async function
this.site_license_manager_set = this.site_license_manager_set.bind(this);
// async function
this.update_site_license_usage_log = this.update_site_license_usage_log.bind(this);
// async function
this.matching_site_licenses = this.matching_site_licenses.bind(this);
// async function
this.manager_site_licenses = this.manager_site_licenses.bind(this);
// async function
this.add_license_to_project = this.add_license_to_project.bind(this);
// async function
this.remove_license_from_project = this.remove_license_from_project.bind(this);
// async function
this.project_datastore_set = this.project_datastore_set.bind(this);
// async function
this.project_datastore_get = this.project_datastore_get.bind(this);
// async function
this.project_datastore_del = this.project_datastore_del.bind(this);
// async function
this.permanently_unlink_all_deleted_projects_of_user = this.permanently_unlink_all_deleted_projects_of_user.bind(this);
// async function
this.unlink_old_deleted_projects = this.unlink_old_deleted_projects.bind(this);
// async function
this.unlist_all_public_paths = this.unlist_all_public_paths.bind(this);
// async
this.projects_that_need_to_be_started = this.projects_that_need_to_be_started.bind(this);
// async
// this *merges* in the run_quota; it doesn't replace it.
this.set_run_quota = this.set_run_quota.bind(this);
// async -- true if they are a manager on a license or have
// any subscriptions.
this.is_paying_customer = this.is_paying_customer.bind(this);
// async
this.get_all_public_paths = this.get_all_public_paths.bind(this);
}
async log(opts) {
var expire, ref, v;
boundMethodCheck(this, PostgreSQL);
opts = defaults(opts, {
event: required, // string
value: required, // object
cb: void 0
});
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 != null) || (v.email_address != null) || (ref = opts.event, indexOf.call(PII_EVENTS, ref) >= 0)) {
expire = (await pii_expire(this));
}
}
return this._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) => {
return typeof opts.cb === "function" ? opts.cb(err) : void 0;
}
});
}
uncaught_exception(err) {
var e;
boundMethodCheck(this, PostgreSQL);
try {
// call when things go to hell in some unexpected way; at least
// we attempt to record this in the database...
return this.log({
event: 'uncaught_exception',
value: {
error: `${err}`,
stack: `${err.stack}`,
host: require('os').hostname()
}
});
} catch (error) {
e = error;
}
}
get_log(opts) {
boundMethodCheck(this, PostgreSQL);
opts = defaults(opts, {
start: void 0, // if not given start at beginning of time
end: void 0, // if not given include everything until now
log: 'central_log', // which table to query
event: void 0,
where: void 0, // 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
});
return this._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)
});
}
get_user_log(opts) {
boundMethodCheck(this, PostgreSQL);
opts = defaults(opts, {
start: void 0,
end: void 0, // if not given include everything until now
event: 'successful_sign_in',
account_id: required,
cb: required
});
return this.get_log({
start: opts.start,
end: opts.end,
event: opts.event,
where: {
account_id: opts.account_id
},
cb: opts.cb
});
}
log_client_error(opts) {
var expire;
boundMethodCheck(this, PostgreSQL);
opts = defaults(opts, {
event: 'event',
error: 'error',
account_id: void 0,
cb: void 0
});
// get rid of the entry in 30 days
expire = misc.expire_time(30 * 24 * 60 * 60);
return this._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) {
var expire;
boundMethodCheck(this, PostgreSQL);
opts = defaults(opts, {
account_id: void 0,
name: void 0,
message: void 0,
comment: void 0,
stacktrace: void 0,
file: void 0,
path: void 0,
lineNumber: void 0,
columnNumber: void 0,
severity: void 0,
browser: void 0,
mobile: void 0,
responsive: void 0,
user_agent: void 0,
smc_version: void 0,
build_date: void 0,
smc_git_rev: void 0,
uptime: void 0,
start_time: void 0,
id: void 0, // ignored
cb: void 0
});
// get rid of the entry in 30 days
expire = misc.expire_time(30 * 24 * 60 * 60);
return this._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) {
boundMethodCheck(this, PostgreSQL);
opts = defaults(opts, {
start: void 0, // if not given start at beginning of time
end: void 0, // if not given include everything until now
event: void 0,
cb: required
});
opts.log = 'client_error_log';
return this.get_log(opts);
}
set_server_setting(opts) {
boundMethodCheck(this, PostgreSQL);
opts = defaults(opts, {
name: required,
value: required,
cb: required
});
return async.series([
function(cb) {
return this._query({
query: 'INSERT INTO server_settings',
values: {
'name::TEXT': opts.name,
'value::TEXT': opts.value
},
conflict: 'name',
cb: cb
});
},
// also set a timestamp
function(cb) {
return this._query({
query: 'INSERT INTO server_settings',
values: {
'name::TEXT': '_last_update',
'value::TEXT': (new Date()).toISOString()
},
conflict: 'name',
cb: cb
});
}
], function(err) {
// clear the cache no matter what (e.g., server_settings might have partly changed then errored)
this.reset_server_settings_cache();
return opts.cb(err);
});
}
reset_server_settings_cache() {
boundMethodCheck(this, PostgreSQL);
SERVER_SETTINGS_CACHE.reset();
return webapp_config_clear_cache();
}
get_server_setting(opts) {
boundMethodCheck(this, PostgreSQL);
opts = defaults(opts, {
name: required,
cb: required
});
return this._query({
query: 'SELECT value FROM server_settings',
where: {
"name = $::TEXT": opts.name
},
cb: one_result('value', opts.cb)
});
}
get_server_settings_cached(opts) {
var dbg, settings;
boundMethodCheck(this, PostgreSQL);
opts = defaults(opts, {
cb: required
});
settings = SERVER_SETTINGS_CACHE.get('server_settings');
if (settings) {
opts.cb(void 0, settings);
return;
}
dbg = this._dbg('get_server_settings_cached');
return this._query({
query: 'SELECT name, value FROM server_settings',
cache: true,
cb: (err, result) => {
var ckey, conf, config, k, l, len, len1, len2, o, q, ref, ref1, ref2, ref3, val, x;
if (err) {
return opts.cb(err);
} else {
x = {
_timestamp: Date.now()
};
ref = result.rows;
// process values, possibly post-process values
for (l = 0, len = ref.length; l < len; l++) {
k = ref[l];
val = k.value;
config = (ref1 = SITE_SETTINGS_CONF[k.name]) != null ? ref1 : SERVER_SETTINGS_EXTRAS[k.name];
if ((config != null ? config.to_val : void 0) != null) {
val = config.to_val(val);
}
x[k.name] = val;
}
ref2 = [SERVER_SETTINGS_EXTRAS, SITE_SETTINGS_CONF];
// set default values for missing keys
for (o = 0, len1 = ref2.length; o < len1; o++) {
config = ref2[o];
ref3 = Object.keys(config);
for (q = 0, len2 = ref3.length; q < len2; q++) {
ckey = ref3[q];
if ((x[ckey] == null) || x[ckey] === '') {
conf = config[ckey];
if ((conf != null ? conf.to_val : void 0) != null) {
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)}")
return opts.cb(void 0, x);
}
}
});
}
get_site_settings(opts) {
boundMethodCheck(this, PostgreSQL);
opts = defaults(opts, {
cb: required // (err, settings)
});
return this._query({
query: 'SELECT name, value FROM server_settings',
cache: true,
where: {
"name = ANY($)": misc.keys(site_settings_conf)
},
cb: (err, result) => {
var k, l, len, ref, ref1, x;
if (err) {
return opts.cb(err);
} else {
x = {};
ref = result.rows;
for (l = 0, len = ref.length; l < len; l++) {
k = ref[l];
if (k.name === 'commercial' && ((ref1 = k.value) === 'true' || ref1 === 'false')) { // backward compatibility
k.value = eval(k.value);
}
x[k.name] = k.value;
}
return opts.cb(void 0, x);
}
}
});
}
server_settings_synctable(opts = {}) {
boundMethodCheck(this, PostgreSQL);
opts.table = 'server_settings';
return this.synctable(opts);
}
set_passport_settings(opts) {
boundMethodCheck(this, PostgreSQL);
opts = defaults(opts, {
strategy: required,
conf: required,
cb: required
});
return this._query({
query: 'INSERT into passport_settings',
values: {
'strategy::TEXT ': opts.strategy,
'conf ::JSONB': opts.conf
},
conflict: 'strategy',
cb: opts.cb
});
}
get_passport_settings(opts) {
boundMethodCheck(this, PostgreSQL);
opts = defaults(opts, {
strategy: required,
cb: required
});
return this._query({
query: 'SELECT conf FROM passport_settings',
where: {
"strategy = $::TEXT": opts.strategy
},
cb: one_result('conf', opts.cb)
});
}
get_all_passport_settings(opts) {
boundMethodCheck(this, PostgreSQL);
opts = defaults(opts, {
cb: required
});
return this._query({
query: 'SELECT strategy, conf FROM passport_settings',
cb: all_results(opts.cb)
});
}
get_all_passport_settings_cached(opts) {
var passports;
boundMethodCheck(this, PostgreSQL);
opts = defaults(opts, {
cb: required
});
passports = SERVER_SETTINGS_CACHE.get('passports');
if (passports) {
opts.cb(void 0, passports);
return;
}
return this.get_all_passport_settings({
cb: (err, res) => {
if (err) {
return opts.cb(err);
} else {
SERVER_SETTINGS_CACHE.set('passports', res);
return opts.cb(void 0, res);
}
}
});
}
get_api_key(opts) {
boundMethodCheck(this, PostgreSQL);
opts = defaults(opts, {
account_id: required,
cb: required
});
return this._query({
query: 'SELECT api_key FROM accounts',
where: {
"account_id = $::UUID": opts.account_id
},
cb: one_result((err, x) => {
var ref;
return opts.cb(err, (ref = x != null ? x.api_key : void 0) != null ? ref : '');
})
});
}
get_account_with_api_key(opts) {
boundMethodCheck(this, PostgreSQL);
opts = defaults(opts, {
api_key: required,
cb: required // cb(err, account_id)
});
return this._query({
query: 'SELECT account_id FROM accounts',
where: {
"api_key = $::TEXT": opts.api_key
},
cb: one_result((err, x) => {
return opts.cb(err, x != null ? x.account_id : void 0);
})
});
}
delete_api_key(opts) {
boundMethodCheck(this, PostgreSQL);
opts = defaults(opts, {
account_id: required,
cb: required
});
return this._query({
query: 'UPDATE accounts SET api_key = NULL',
where: {
"account_id = $::UUID": opts.account_id
},
cb: opts.cb
});
}
regenerate_api_key(opts) {
var api_key;
boundMethodCheck(this, PostgreSQL);
opts = defaults(opts, {
account_id: required,
cb: required
});
api_key = 'sk_' + random_key.generate(24);
return this._query({
query: 'UPDATE accounts',
set: {
api_key: api_key
},
where: {
"account_id = $::UUID": opts.account_id
},
cb: (err) => {
return opts.cb(err, api_key);
}
});
}
create_account(opts = {}) {
var account_id, dbg, l, last, len, name, passport_key, ref, test;
boundMethodCheck(this, PostgreSQL);
opts = defaults(opts, {
first_name: void 0,
last_name: void 0,
created_by: void 0, // ip address of computer creating this account
email_address: void 0,
password_hash: void 0,
lti_id: void 0, // 2-tuple <string[]>[iss, user_id]
passport_strategy: void 0,
passport_id: void 0,
passport_profile: void 0,
usage_intent: void 0,
cb: required // cb(err, account_id)
});
dbg = this._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();
ref = ['first_name', 'last_name'];
for (l = 0, len = ref.length; l < len; l++) {
name = ref[l];
if (opts[name]) {
test = misc2_node.is_valid_username(opts[name]);
if (test != null) {
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 = void 0;
if (opts.passport_strategy != null) {
// 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...
if (this._create_account_passport_keys == null) {
this._create_account_passport_keys = {};
}
passport_key = this._passport_key({
strategy: opts.passport_strategy,
id: opts.passport_id
});
last = this._create_account_passport_keys[passport_key];
if ((last != null) && new Date() - last <= 60 * 1000) {
opts.cb("recent attempt to make account with this passport strategy");
return;
}
this._create_account_passport_keys[passport_key] = new Date();
}
return async.series([
(cb) => {
if (opts.passport_strategy == null) {
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!
return this.passport_exists({
strategy: opts.passport_strategy,
id: opts.passport_id,
cb: function(err,
account_id) {
if (err) {
return cb(err);
} else if (account_id) {
return cb(`account with email passport strategy '${opts.passport_strategy}' and id '${opts.passport_id}' already exists`);
} else {
return cb();
}
}
});
},
(cb) => {
dbg("create the actual account");
return this._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 != null) {
dbg("add passport authentication strategy");
return this.create_passport({
account_id: account_id,
strategy: opts.passport_strategy,
id: opts.passport_id,
profile: opts.passport_profile,
cb: cb
});
} else {
return cb();
}
}
], (err) => {
if (err) {
dbg(`error creating account -- ${err}`);
return opts.cb(err);
} else {
dbg("successfully created account");
return opts.cb(void 0, account_id);
}
});
}
is_admin(opts) {
boundMethodCheck(this, PostgreSQL);
opts = defaults(opts, {
account_id: required,
cb: required
});
return this._query({
query: "SELECT groups FROM accounts",
where: {
'account_id = $::UUID': opts.account_id
},
cache: true,
cb: one_result('groups', (err, groups) => {
return opts.cb(err, (groups != null) && indexOf.call(groups, 'admin') >= 0);
})
});
}
user_is_in_group(opts) {
boundMethodCheck(this, PostgreSQL);
opts = defaults(opts, {
account_id: required,
group: required,
cb: required
});
return this._query({
query: "SELECT groups FROM accounts",
where: {
'account_id = $::UUID': opts.account_id
},
cache: true,
cb: one_result('groups', (err, groups) => {
var ref;
return opts.cb(err, (groups != null) && (ref = opts.group, indexOf.call(groups, ref) >= 0));
})
});
}
make_user_admin(opts) {
boundMethodCheck(this, PostgreSQL);
opts = defaults(opts, {
account_id: void 0,
email_address: void 0,
cb: required
});
if ((opts.account_id == null) && (opts.email_address == null)) {
if (typeof opts.cb === "functio