smc-hub
Version:
CoCalc: Backend webserver component
1,340 lines (1,241 loc) • 109 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
//########################################################################
/*
Client = a client that is connected via a persistent connection to the hub
*/
var CACHE_PROJECT_AUTH_MS, CLIENT_DESTROY_TIMER_S, CLIENT_METRICS_INTERVAL_S, CLIENT_MIN_ACTIVE_S, COOKIE_OPTIONS, Cookies, CopyPath, DEBUG2, EventEmitter, JSON_CHANNEL, MESG_QUEUE_INTERVAL_MS, MESG_QUEUE_MAX_COUNT, MESG_QUEUE_MAX_SIZE_MB, MESG_QUEUE_MAX_WARN, PW_RESET_ENDPOINT, PW_RESET_KEY, REQUIRE_ACCOUNT_TO_EXECUTE_CODE, StripeClient, access, api_key_action, async, auth, auth_token, base_path, callback, callback2, client_metrics, clients, create_account, db_schema, defaults, delete_account, escapeHtml, get_personal_user, get_support, hub_projects, is_paying_customer, local_hub_connection, mesg_from_client_total, message, metrics_recorder, misc, password, path_join, project_has_network_access, purchase_license, push_to_client_stats_h, record_user_tracking, ref, remember_me_cookie_name, required, send_email, send_invite_email, sign_in, to_safe_str, underscore, uuid,
boundMethodCheck = function(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new Error('Bound instance method accessed before binding'); } },
indexOf = [].indexOf;
({EventEmitter} = require('events'));
uuid = require('node-uuid');
async = require('async');
// TODO: I'm very suspicious about this sameSite:"none" config option.
exports.COOKIE_OPTIONS = COOKIE_OPTIONS = Object.freeze({
secure: true,
sameSite: 'none'
});
Cookies = require('cookies'); // https://github.com/jed/cookies
misc = require('smc-util/misc');
({defaults, required, to_safe_str} = misc);
message = require('smc-util/message');
access = require('./access');
clients = require('./clients').getClients();
auth = require('./auth');
auth_token = require('./auth-token');
password = require('./password');
local_hub_connection = require('./local_hub_connection');
sign_in = require('./sign-in');
hub_projects = require('./projects');
({StripeClient} = require('./stripe/client'));
({get_support} = require('./support'));
({send_email, send_invite_email} = require('./email'));
({api_key_action} = require('./api/manage'));
({create_account, delete_account} = require('./client/create-account'));
({purchase_license} = require('./client/license'));
db_schema = require('smc-util/db-schema');
({escapeHtml} = require("escape-html"));
({CopyPath} = require('./copy-path'));
({remember_me_cookie_name} = require('./auth'));
path_join = require('path').join;
base_path = require('smc-util-node/base-path').default;
underscore = require('underscore');
({callback} = require('awaiting'));
({callback2} = require('smc-util/async-utils'));
({record_user_tracking} = require('./postgres/user-tracking'));
({project_has_network_access} = require('./postgres/project-queries'));
({is_paying_customer} = require('./postgres/account-queries'));
({get_personal_user} = require('./postgres/personal'));
({PW_RESET_ENDPOINT, PW_RESET_KEY} = require('./password'));
DEBUG2 = !!process.env.SMC_DEBUG2;
REQUIRE_ACCOUNT_TO_EXECUTE_CODE = false;
// Temporarily to handle old clients for a few days.
JSON_CHANNEL = '\u0000';
// Anti DOS parameters:
// If a client sends a burst of messages, we space handling them out by this many milliseconds:
// (this even includes keystrokes when using the terminal)
MESG_QUEUE_INTERVAL_MS = 0;
// If a client sends a massive burst of messages, we discard all but the most recent this many of them:
// The client *should* be implemented in a way so that this never happens, and when that is
// the case -- according to our loging -- we might switch to immediately banning clients that
// hit these limits...
MESG_QUEUE_MAX_COUNT = 300;
MESG_QUEUE_MAX_WARN = 50;
// Any messages larger than this is dropped (it could take a long time to handle, by a de-JSON'ing attack, etc.).
// On the other hand, it is good to make this large enough that projects can save
MESG_QUEUE_MAX_SIZE_MB = 20;
// How long to cache a positive authentication for using a project.
CACHE_PROJECT_AUTH_MS = 1000 * 60 * 15; // 15 minutes
// How long all info about a websocket Client connection
// is kept in memory after a user disconnects. This makes it
// so that if they quickly reconnect, the connections to projects
// and other state doesn't have to be recomputed.
CLIENT_DESTROY_TIMER_S = 60 * 10; // 10 minutes
//CLIENT_DESTROY_TIMER_S = 0.1 # instant -- for debugging
CLIENT_MIN_ACTIVE_S = 45;
// How frequently we tell the browser clients to report metrics back to us.
// Set to 0 to completely disable metrics collection from clients.
CLIENT_METRICS_INTERVAL_S = DEBUG2 ? 15 : 60 * 2;
// recording metrics and statistics
metrics_recorder = require('./metrics-recorder');
// setting up client metrics
mesg_from_client_total = metrics_recorder.new_counter('mesg_from_client_total', 'counts Client::handle_json_message_from_client invocations', ['event']);
push_to_client_stats_h = metrics_recorder.new_histogram('push_to_client_histo_ms', 'Client: push_to_client', {
buckets: [1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000],
labels: ['event']
});
// All known metrics from connected clients. (Map from id to metrics.)
// id is deleted from this when client disconnects.
client_metrics = metrics_recorder.client_metrics;
if (!misc.is_object(client_metrics)) {
throw Error("metrics_recorder must have a client_metrics attribute map");
}
ref = exports.Client = class Client extends EventEmitter {
constructor(opts) {
super();
this.init_conn = this.init_conn.bind(this);
this.touch = this.touch.bind(this);
this.install_conn_handlers = this.install_conn_handlers.bind(this);
this.dbg = this.dbg.bind(this);
this.destroy = this.destroy.bind(this);
this.remember_me_failed = this.remember_me_failed.bind(this);
this.get_personal_user = this.get_personal_user.bind(this);
this.check_for_remember_me = this.check_for_remember_me.bind(this);
this.push_to_client = this.push_to_client.bind(this);
this.error_to_client = this.error_to_client.bind(this);
this.success_to_client = this.success_to_client.bind(this);
this.signed_in = this.signed_in.bind(this);
this.signed_out = this.signed_out.bind(this);
// Setting and getting HTTP-only cookies via Primus + AJAX
this.get_cookie = this.get_cookie.bind(this);
this.set_cookie = this.set_cookie.bind(this);
this.remember_me = this.remember_me.bind(this);
this.invalidate_remember_me = this.invalidate_remember_me.bind(this);
/*
Our realtime socket connection might only support one connection
between the client and
server, so we multiplex multiple channels over the same
connection. There is one base channel for JSON messages called
JSON_CHANNEL, which themselves can be routed to different
callbacks, etc., by the client code. There are 16^4-1 other
channels, which are for sending raw data. The raw data messages
are prepended with a UTF-16 character that identifies the
channel. The channel character is random (which might be more
secure), and there is no relation between the channels for two
distinct clients.
*/
this.handle_data_from_client = this.handle_data_from_client.bind(this);
/*
Message handling functions:
Each function below that starts with mesg_ handles a given
message type (an event). The implementations of many of the
handlers are somewhat long/involved, so the function below
immediately calls another function defined elsewhere. This will
make it easier to refactor code to other modules, etc., later.
This approach also clarifies what exactly about this object
is used to implement the relevant functionality.
*/
this.handle_json_message_from_client = this.handle_json_message_from_client.bind(this);
this.mesg_ping = this.mesg_ping.bind(this);
// Messages: Account creation, deletion, sign in, sign out
this.mesg_create_account = this.mesg_create_account.bind(this);
this.mesg_delete_account = this.mesg_delete_account.bind(this);
this.mesg_sign_in = this.mesg_sign_in.bind(this);
this.mesg_sign_in_using_auth_token = this.mesg_sign_in_using_auth_token.bind(this);
this.mesg_sign_out = this.mesg_sign_out.bind(this);
// Messages: Password/email address management
this.mesg_change_password = this.mesg_change_password.bind(this);
this.mesg_forgot_password = this.mesg_forgot_password.bind(this);
this.mesg_reset_forgot_password = this.mesg_reset_forgot_password.bind(this);
this.mesg_change_email_address = this.mesg_change_email_address.bind(this);
this.mesg_send_verification_email = this.mesg_send_verification_email.bind(this);
this.mesg_unlink_passport = this.mesg_unlink_passport.bind(this);
// Messages: Account settings
this.get_groups = this.get_groups.bind(this);
// Messages: Log errors that client sees so we can also look at them
this.mesg_log_client_error = this.mesg_log_client_error.bind(this);
this.mesg_webapp_error = this.mesg_webapp_error.bind(this);
// Messages: Project Management
this.get_project = this.get_project.bind(this);
this.mesg_create_project = this.mesg_create_project.bind(this);
this.mesg_write_text_file_to_project = this.mesg_write_text_file_to_project.bind(this);
this.mesg_read_text_files_from_projects = this.mesg_read_text_files_from_projects.bind(this);
this.mesg_read_text_file_from_project = this.mesg_read_text_file_from_project.bind(this);
this.mesg_project_exec = this.mesg_project_exec.bind(this);
this.mesg_copy_path_between_projects = this.mesg_copy_path_between_projects.bind(this);
this.mesg_copy_path_status = this.mesg_copy_path_status.bind(this);
this.mesg_copy_path_delete = this.mesg_copy_path_delete.bind(this);
this.mesg_local_hub = this.mesg_local_hub.bind(this);
this.mesg_user_search = this.mesg_user_search.bind(this);
// this is an async function
this.allow_urls_in_emails = this.allow_urls_in_emails.bind(this);
this.mesg_invite_collaborator = this.mesg_invite_collaborator.bind(this);
this.mesg_add_license_to_project = this.mesg_add_license_to_project.bind(this);
this.mesg_remove_license_from_project = this.mesg_remove_license_from_project.bind(this);
this.mesg_invite_noncloud_collaborators = this.mesg_invite_noncloud_collaborators.bind(this);
this.mesg_remove_collaborator = this.mesg_remove_collaborator.bind(this);
// NOTE: this is different than invite_collab, in that it is
// much more similar to remove_collaborator. It also supports
// adding multiple collabs to multiple projects (NOT in one
// transaction, though).
this.mesg_add_collaborator = this.mesg_add_collaborator.bind(this);
this.mesg_remove_blob_ttls = this.mesg_remove_blob_ttls.bind(this);
this.mesg_version = this.mesg_version.bind(this);
this.push_version_update = this.push_version_update.bind(this);
this._user_is_in_group = this._user_is_in_group.bind(this);
this.assert_user_is_in_group = this.assert_user_is_in_group.bind(this);
this.mesg_project_set_quotas = this.mesg_project_set_quotas.bind(this);
/*
Public/published projects data
*/
this.path_is_in_public_paths = this.path_is_in_public_paths.bind(this);
this.get_public_project = this.get_public_project.bind(this);
this.mesg_copy_public_path_between_projects = this.mesg_copy_public_path_between_projects.bind(this);
/*
Data Query
*/
this.mesg_query = this.mesg_query.bind(this);
this.query_cancel_all_changefeeds = this.query_cancel_all_changefeeds.bind(this);
this.mesg_query_cancel = this.mesg_query_cancel.bind(this);
this.mesg_get_usernames = this.mesg_get_usernames.bind(this);
/*
Support Tickets → Zendesk
*/
this.mesg_create_support_ticket = this.mesg_create_support_ticket.bind(this);
this.mesg_get_support_tickets = this.mesg_get_support_tickets.bind(this);
/*
Stripe-integration billing code
*/
this.handle_stripe_mesg = this.handle_stripe_mesg.bind(this);
this.mesg_stripe_get_customer = this.mesg_stripe_get_customer.bind(this);
this.mesg_stripe_create_source = this.mesg_stripe_create_source.bind(this);
this.mesg_stripe_delete_source = this.mesg_stripe_delete_source.bind(this);
this.mesg_stripe_set_default_source = this.mesg_stripe_set_default_source.bind(this);
this.mesg_stripe_update_source = this.mesg_stripe_update_source.bind(this);
this.mesg_stripe_create_subscription = this.mesg_stripe_create_subscription.bind(this);
this.mesg_stripe_cancel_subscription = this.mesg_stripe_cancel_subscription.bind(this);
this.mesg_stripe_update_subscription = this.mesg_stripe_update_subscription.bind(this);
this.mesg_stripe_get_subscriptions = this.mesg_stripe_get_subscriptions.bind(this);
this.mesg_stripe_get_coupon = this.mesg_stripe_get_coupon.bind(this);
this.mesg_stripe_get_charges = this.mesg_stripe_get_charges.bind(this);
this.mesg_stripe_get_invoices = this.mesg_stripe_get_invoices.bind(this);
this.mesg_stripe_admin_create_invoice_item = this.mesg_stripe_admin_create_invoice_item.bind(this);
this.mesg_get_available_upgrades = this.mesg_get_available_upgrades.bind(this);
this.mesg_remove_all_upgrades = this.mesg_remove_all_upgrades.bind(this);
this.mesg_stripe_sync_site_license_subscriptions = this.mesg_stripe_sync_site_license_subscriptions.bind(this);
this.mesg_purchase_license = this.mesg_purchase_license.bind(this);
// END stripe-related functionality
this.mesg_api_key = this.mesg_api_key.bind(this);
this.mesg_user_auth = this.mesg_user_auth.bind(this);
this.mesg_revoke_auth_token = this.mesg_revoke_auth_token.bind(this);
// Receive and store in memory the latest metrics status from the client.
this.mesg_metrics = this.mesg_metrics.bind(this);
//dbg('RECORDED: ', misc.to_json(client_metrics[@id]))
this._check_project_access = this._check_project_access.bind(this);
this._check_syncdoc_access = this._check_syncdoc_access.bind(this);
this.mesg_disconnect_from_project = this.mesg_disconnect_from_project.bind(this);
this.mesg_touch_project = this.mesg_touch_project.bind(this);
this.mesg_get_syncdoc_history = this.mesg_get_syncdoc_history.bind(this);
this.mesg_user_tracking = this.mesg_user_tracking.bind(this);
this.mesg_admin_reset_password = this.mesg_admin_reset_password.bind(this);
this.mesg_admin_ban_user = this.mesg_admin_ban_user.bind(this);
this._opts = defaults(opts, {
conn: void 0,
logger: void 0,
database: required,
compute_server: required,
host: void 0,
port: void 0,
personal: void 0
});
this.conn = this._opts.conn;
this.logger = this._opts.logger;
this.database = this._opts.database;
this.compute_server = this._opts.compute_server;
this._when_connected = new Date();
this._messages = {
being_handled: {},
total_time: 0,
count: 0
};
// The variable account_id is either undefined or set to the
// account id of the user that this session has successfully
// authenticated as. Use @account_id to decide whether or not
// it is safe to carry out a given action.
this.account_id = void 0;
if (this.conn != null) {
// has a persistent connection, e.g., NOT just used for an API
this.init_conn();
} else {
this.id = misc.uuid();
}
this.copy_path = new CopyPath(this);
}
init_conn() {
var c;
boundMethodCheck(this, ref);
// initialize everything related to persistent connections
this._data_handlers = {};
this._data_handlers[JSON_CHANNEL] = this.handle_json_message_from_client;
// The persistent sessions that this client starts.
this.compute_session_uuids = [];
this.install_conn_handlers();
this.ip_address = this.conn.address.ip;
// A unique id -- can come in handy
this.id = this.conn.id;
// Setup remember-me related cookie handling
this.cookies = {};
c = new Cookies(this.conn.request, COOKIE_OPTIONS);
this._remember_me_value = c.get(remember_me_cookie_name());
this.check_for_remember_me();
// Security measure: check every 5 minutes that remember_me
// cookie used for login is still valid. If the cookie is gone
// and this fails, user gets a message, and see that they must sign in.
this._remember_me_interval = setInterval(this.check_for_remember_me, 1000 * 60 * 5);
if (CLIENT_METRICS_INTERVAL_S) {
return this.push_to_client(message.start_metrics({
interval_s: CLIENT_METRICS_INTERVAL_S
}));
}
}
touch(opts = {}) {
var key;
boundMethodCheck(this, ref);
if (!this.account_id) { // not logged in
if (typeof opts.cb === "function") {
opts.cb('not logged in');
}
return;
}
opts = defaults(opts, {
project_id: void 0,
path: void 0,
action: 'edit',
force: false,
cb: void 0
});
// touch -- indicate by changing field in database that this user is active.
// We do this at most once every CLIENT_MIN_ACTIVE_S seconds, for given choice
// of project_id, path (unless force is true).
if (this._touch_lock == null) {
this._touch_lock = {};
}
key = `${opts.project_id}-${opts.path}-${opts.action}`;
if (!opts.force && this._touch_lock[key]) {
if (typeof opts.cb === "function") {
opts.cb("touch lock");
}
return;
}
opts.account_id = this.account_id;
this._touch_lock[key] = true;
delete opts.force;
this.database.touch(opts);
return setTimeout((() => {
return delete this._touch_lock[key];
}), CLIENT_MIN_ACTIVE_S * 1000);
}
install_conn_handlers() {
var dbg;
boundMethodCheck(this, ref);
dbg = this.dbg('install_conn_handlers');
if (this._destroy_timer != null) {
clearTimeout(this._destroy_timer);
delete this._destroy_timer;
}
this.conn.on("data", (data) => {
return this.handle_data_from_client(data);
});
this.conn.on("end", () => {
dbg(`connection: hub <--> client(id=${this.id}, address=${this.ip_address}) -- CLOSED`);
return this.destroy();
});
/*
* I don't think this destroy_timer is of any real value at all unless
* we were to fully maintain client state while they are gone. Doing this
* is a serious liability, e.g., in a load-spike situation.
* CRITICAL -- of course we need to cancel all changefeeds when user disconnects,
* even temporarily, since messages could be dropped otherwise. (The alternative is to
* cache all messages in the hub, which has serious memory implications.)
@query_cancel_all_changefeeds()
* Actually destroy Client in a few minutes, unless user reconnects
* to this session. Often the user may have a temporary network drop,
* and we keep everything waiting for them for short time
* in case this happens.
@_destroy_timer = setTimeout(@destroy, 1000*CLIENT_DESTROY_TIMER_S)
*/
return dbg(`connection: hub <--> client(id=${this.id}, address=${this.ip_address}) ESTABLISHED`);
}
dbg(desc) {
var ref1;
boundMethodCheck(this, ref);
if ((ref1 = this.logger) != null ? ref1.debug : void 0) {
return (...args) => {
return this.logger.debug(`Client(${this.id}).${desc}:`, ...args);
};
} else {
return function() {};
}
}
destroy() {
var c, dbg, f, h, id, j, len, ref1, ref2, results1;
boundMethodCheck(this, ref);
dbg = this.dbg('destroy');
dbg(`destroy connection: hub <--> client(id=${this.id}, address=${this.ip_address}) -- CLOSED`);
if (this.id) {
// cancel any outstanding queries.
this.database.cancel_user_queries({
client_id: this.id
});
}
delete this._project_cache;
delete client_metrics[this.id];
clearInterval(this._remember_me_interval);
this.query_cancel_all_changefeeds();
this.closed = true;
this.emit('close');
this.compute_session_uuids = [];
c = clients[this.id];
delete clients[this.id];
dbg(`num_clients=${misc.len(clients)}`);
if ((c != null) && (c.call_callbacks != null)) {
ref1 = c.call_callbacks;
for (id in ref1) {
f = ref1[id];
f("connection closed");
}
delete c.call_callbacks;
}
ref2 = local_hub_connection.all_local_hubs();
results1 = [];
for (j = 0, len = ref2.length; j < len; j++) {
h = ref2[j];
results1.push(h.free_resources_for_client_id(this.id));
}
return results1;
}
remember_me_failed(reason) {
boundMethodCheck(this, ref);
if (this.conn == null) {
return;
}
this.signed_out(); // so can't do anything with projects, etc.
return this.push_to_client(message.remember_me_failed({
reason: reason
}));
}
async get_personal_user() {
var dbg, err, signed_in_mesg;
boundMethodCheck(this, ref);
if (this.account_id || (this.conn == null) || !this._opts.personal) {
return;
}
// there is only one account
dbg = this.dbg("check_for_remember_me");
dbg("personal mode");
try {
signed_in_mesg = {
account_id: (await get_personal_user(this.database)),
event: 'signed_in'
};
// sign them in if not already signed in (due to async this could happen
// by get_personal user getting called twice at once).
if (this.account_id !== signed_in_mesg.account_id) {
signed_in_mesg.hub = this._opts.host + ':' + this._opts.port;
this.signed_in(signed_in_mesg);
this.push_to_client(signed_in_mesg);
}
} catch (error1) {
err = error1;
dbg("remember_me: personal mode error", err.toString());
this.remember_me_failed(`error getting personal user -- ${err}`);
}
}
check_for_remember_me() {
var dbg, err, hash, value, x;
boundMethodCheck(this, ref);
if (this.conn == null) {
return;
}
dbg = this.dbg("check_for_remember_me");
if (this._opts.personal) {
this.get_personal_user();
return;
}
value = this._remember_me_value;
if (value == null) {
this.remember_me_failed("no remember_me cookie");
return;
}
x = value.split('$');
if (x.length !== 4) {
this.remember_me_failed("invalid remember_me cookie");
return;
}
try {
hash = auth.generate_hash(x[0], x[1], x[2], x[3]);
} catch (error1) {
err = error1;
dbg(`unable to generate hash from '${value}' -- ${err}`);
this.remember_me_failed("invalid remember_me cookie");
return;
}
dbg(`checking for remember_me cookie with hash='${hash.slice(0, 15)}...'`);
return this.database.get_remember_me({
hash: hash,
cb: (error, signed_in_mesg) => {
dbg("remember_me: got error", error, "signed_in_mesg", signed_in_mesg);
if (error) {
this.remember_me_failed("error accessing database");
return;
}
if (signed_in_mesg == null) {
this.remember_me_failed("remember_me deleted or expired");
return;
}
// sign them in if not already signed in
if (this.account_id !== signed_in_mesg.account_id) {
signed_in_mesg.hub = this._opts.host + ':' + this._opts.port;
this.hash_session_id = hash;
this.signed_in(signed_in_mesg);
return this.push_to_client(signed_in_mesg);
}
}
});
}
push_to_client(mesg, cb) {
var avg, data, dbg, f, listen, start, t, time_taken, tm;
boundMethodCheck(this, ref);
/*
Pushing messages to this particular connected client
*/
if (this.closed) {
if (typeof cb === "function") {
cb("disconnected");
}
return;
}
dbg = this.dbg("push_to_client");
if (mesg.event !== 'pong') {
dbg(`hub --> client (client=${this.id}): ${misc.trunc(to_safe_str(mesg), 300)}`);
}
//dbg("hub --> client (client=#{@id}): #{misc.trunc(JSON.stringify(mesg),1000)}")
//dbg("hub --> client (client=#{@id}): #{JSON.stringify(mesg)}")
if (mesg.id != null) {
start = this._messages.being_handled[mesg.id];
if (start != null) {
time_taken = new Date() - start;
delete this._messages.being_handled[mesg.id];
this._messages.total_time += time_taken;
this._messages.count += 1;
avg = Math.round(this._messages.total_time / this._messages.count);
dbg(`[${time_taken} mesg_time_ms] [${avg} mesg_avg_ms] -- mesg.id=${mesg.id}`);
push_to_client_stats_h.observe({
event: mesg.event
}, time_taken);
}
}
// If cb *is* given and mesg.id is *not* defined, then
// we also setup a listener for a response from the client.
listen = (cb != null) && (mesg.id == null);
if (listen) {
// This message is not a response to a client request.
// Instead, we are initiating a request to the user and we
// want a result back (hence cb? being defined).
mesg.id = misc.uuid();
if (this.call_callbacks == null) {
this.call_callbacks = {};
}
this.call_callbacks[mesg.id] = cb;
f = () => {
var g, ref1;
g = (ref1 = this.call_callbacks) != null ? ref1[mesg.id] : void 0;
if (g != null) {
delete this.call_callbacks[mesg.id];
return g("timed out");
}
};
setTimeout(f, 15000); // timeout after some seconds
}
t = new Date();
data = misc.to_json_socket(mesg);
tm = new Date() - t;
if (tm > 10) {
dbg(`mesg.id=${mesg.id}: time to json=${tm}ms; length=${data.length}; value='${misc.trunc(data, 500)}'`);
}
this.push_data_to_client(data);
if (!listen) {
if (typeof cb === "function") {
cb();
}
}
}
push_data_to_client(data) {
if (this.conn == null) {
return;
}
if (this.closed) {
return;
}
return this.conn.write(data);
}
error_to_client(opts) {
boundMethodCheck(this, ref);
opts = defaults(opts, {
id: void 0,
error: required
});
if (opts.error instanceof Error) {
// Javascript Errors as come up with exceptions don't JSON.
// Since the point is just to show an error to the client,
// it is better to send back the string!
opts.error = opts.error.toString();
}
return this.push_to_client(message.error({
id: opts.id,
error: opts.error
}));
}
success_to_client(opts) {
boundMethodCheck(this, ref);
opts = defaults(opts, {
id: required
});
return this.push_to_client(message.success({
id: opts.id
}));
}
signed_in(signed_in_mesg) {
boundMethodCheck(this, ref);
if (this.conn == null) {
return;
}
// Call this method when the user has successfully signed in.
this.signed_in_mesg = signed_in_mesg; // save it, since the properties are handy to have.
// Record that this connection is authenticated as user with given uuid.
this.account_id = signed_in_mesg.account_id;
sign_in.record_sign_in({
ip_address: this.ip_address,
successful: true,
remember_me: signed_in_mesg.remember_me, // True if sign in accomplished via rememember me token.
email_address: signed_in_mesg.email_address,
account_id: signed_in_mesg.account_id,
database: this.database
});
// Get user's group from database.
return this.get_groups();
}
signed_out() {
boundMethodCheck(this, ref);
return this.account_id = void 0;
}
get_cookie(opts) {
var ref1;
boundMethodCheck(this, ref);
opts = defaults(opts, {
name: required,
cb: required // cb(undefined, value)
});
if (((ref1 = this.conn) != null ? ref1.id : void 0) == null) {
return;
}
// no connection or connection died
this.once(`get_cookie-${opts.name}`, function(value) {
return opts.cb(value);
});
return this.push_to_client(message.cookies({
id: this.conn.id,
get: opts.name,
url: path_join(base_path, "cookies")
}));
}
set_cookie(opts) {
var options, ref1;
boundMethodCheck(this, ref);
opts = defaults(opts, {
name: required,
value: required,
ttl: void 0 // time in seconds until cookie expires
});
if (((ref1 = this.conn) != null ? ref1.id : void 0) == null) {
return;
}
// no connection or connection died
options = {};
if (opts.ttl != null) {
options.expires = new Date(new Date().getTime() + 1000 * opts.ttl);
}
this.cookies[opts.name] = {
value: opts.value,
options: options
};
return this.push_to_client(message.cookies({
id: this.conn.id,
set: opts.name,
url: path_join(base_path, "cookies"),
value: opts.value
}));
}
remember_me(opts) {
var opts0, session_id, signed_in_mesg, ttl, x;
boundMethodCheck(this, ref);
if (this.conn == null) {
return;
}
/*
Remember me. There are many ways to implement
"remember me" functionality in a web app. Here's how
we do it with CoCalc: We generate a random uuid,
which along with salt, is stored in the user's
browser as an httponly cookie. We password hash the
random uuid and store that in our database. When
the user later visits the SMC site, their browser
sends the cookie, which the server hashes to get the
key for the database table, which has corresponding
value the mesg needed for sign in. We then sign the
user in using that message.
The reason we use a password hash is that if
somebody gains access to an entry in the key:value
store of the database, we want to ensure that they
can't use that information to login. The only way
they could login would be by gaining access to the
cookie in the user's browser.
There is no point in signing the cookie since its
contents are random.
Regarding ttl, we use 1 year. The database will forget
the cookie automatically at the same time that the
browser invalidates it.
*/
// WARNING: The code below is somewhat replicated in
// passport_login.
opts = defaults(opts, {
lti_id: void 0,
account_id: required,
ttl: 24 * 3600 * 30, // 30 days, by default
cb: void 0
});
ttl = opts.ttl;
delete opts.ttl;
opts.hub = this._opts.host;
opts.remember_me = true;
opts0 = misc.copy(opts);
delete opts0.cb;
signed_in_mesg = message.signed_in(opts0);
session_id = uuid.v4();
this.hash_session_id = auth.password_hash(session_id);
x = this.hash_session_id.split('$'); // format: algorithm$salt$iterations$hash
this._remember_me_value = [x[0], x[1], x[2], session_id].join('$');
this.set_cookie({ // same name also hardcoded in the client!
name: remember_me_cookie_name(),
value: this._remember_me_value,
ttl: ttl
});
return this.database.save_remember_me({
account_id: opts.account_id,
hash: this.hash_session_id,
value: signed_in_mesg,
ttl: ttl,
cb: opts.cb
});
}
invalidate_remember_me(opts) {
boundMethodCheck(this, ref);
if (this.conn == null) {
return;
}
opts = defaults(opts, {
cb: required
});
if (this.hash_session_id != null) {
return this.database.delete_remember_me({
hash: this.hash_session_id,
cb: opts.cb
});
} else {
return opts.cb();
}
}
handle_data_from_client(data) {
var dbg, msg, ref1;
boundMethodCheck(this, ref);
if (this.conn == null) {
return;
}
dbg = this.dbg("handle_data_from_client");
//# Only enable this when doing low level debugging -- performance impacts AND leakage of dangerous info!
if (DEBUG2) {
dbg(`handle_data_from_client('${misc.trunc(data.toString(), 400)}')`);
}
// TODO: THIS IS A SIMPLE anti-DOS measure; it might be too
// extreme... we shall see. It prevents a number of attacks,
// e.g., users storing a multi-gigabyte worksheet title,
// etc..., which would (and will) otherwise require care with
// every single thing we store.
// TODO: the two size things below should be specific messages (not generic error_to_client), and
// be sensibly handled by the client.
if (data.length >= MESG_QUEUE_MAX_SIZE_MB * 10000000) {
// We don't parse it, we don't look at it, we don't know it's id. This shouldn't ever happen -- and probably would only
// happen because of a malicious attacker. JSON parsing arbitrarily large strings would
// be very dangerous, and make crashing the server way too easy.
// We just respond with this error below. The client should display to the user all id-less errors.
msg = `The server ignored a huge message since it exceeded the allowed size limit of ${MESG_QUEUE_MAX_SIZE_MB}MB. Please report what caused this if you can.`;
if ((ref1 = this.logger) != null) {
ref1.error(msg);
}
this.error_to_client({
error: msg
});
return;
}
if (this._handle_data_queue == null) {
this._handle_data_queue = [];
}
// The rest of the function is basically the same as "h(data.slice(1))", except that
// it ensure that if there is a burst of messages, then (1) we handle at most 1 message
// per client every MESG_QUEUE_INTERVAL_MS, and we drop messages if there are too many.
// This is an anti-DOS measure.
this._handle_data_queue.push([this.handle_json_message_from_client, data]);
if (this._handle_data_queue_empty_function != null) {
return;
}
// define a function to empty the queue
this._handle_data_queue_empty_function = () => {
var discarded_mesg, task;
if (this._handle_data_queue.length === 0) {
// done doing all tasks
delete this._handle_data_queue_empty_function;
return;
}
if (this._handle_data_queue.length > MESG_QUEUE_MAX_WARN) {
dbg(`MESG_QUEUE_MAX_WARN(=${MESG_QUEUE_MAX_WARN}) exceeded (=${this._handle_data_queue.length}) -- just a warning`);
}
// drop oldest message to keep
if (this._handle_data_queue.length > MESG_QUEUE_MAX_COUNT) {
dbg(`MESG_QUEUE_MAX_COUNT(=${MESG_QUEUE_MAX_COUNT}) exceeded (=${this._handle_data_queue.length}) -- drop oldest messages`);
while (this._handle_data_queue.length > MESG_QUEUE_MAX_COUNT) {
discarded_mesg = this._handle_data_queue.shift();
data = discarded_mesg != null ? discarded_mesg[1] : void 0;
dbg(`discarded_mesg='${misc.trunc(data != null ? typeof data.toString === "function" ? data.toString() : void 0 : void 0, 1000)}'`);
}
}
// get task
task = this._handle_data_queue.shift();
// do task
task[0](task[1]);
// do next one in >= MESG_QUEUE_INTERVAL_MS
return setTimeout(this._handle_data_queue_empty_function, MESG_QUEUE_INTERVAL_MS);
};
return this._handle_data_queue_empty_function();
}
register_data_handler(h) {
var channel;
if (this.conn == null) {
return;
}
// generate a channel character that isn't already taken -- if these get too large,
// this will break (see, e.g., http://blog.fgribreau.com/2012/05/how-to-fix-could-not-decode-text-frame.html);
// however, this is a counter for *each* individual user connection, so they won't get too big.
// Ultimately, we'll redo things to use primus/websocket channel support, which should be much more powerful
// and faster.
if (this._last_channel == null) {
this._last_channel = 1;
}
while (true) {
this._last_channel += 1;
channel = String.fromCharCode(this._last_channel);
if (this._data_handlers[channel] == null) {
break;
}
}
this._data_handlers[channel] = h;
return channel;
}
handle_json_message_from_client(data) {
var dbg, error, f, handler, mesg, ref1;
boundMethodCheck(this, ref);
if (this.conn == null) {
return;
}
if (this._ignore_client) {
return;
}
try {
mesg = misc.from_json_socket(data);
} catch (error1) {
error = error1;
if ((ref1 = this.logger) != null) {
ref1.error(`error parsing incoming mesg (invalid JSON): ${mesg}`);
}
return;
}
dbg = this.dbg('handle_json_message_from_client');
if (mesg.event !== 'ping') {
dbg(`hub <-- client: ${misc.trunc(to_safe_str(mesg), 120)}`);
}
// check for message that is coming back in response to a request from the hub
if ((this.call_callbacks != null) && (mesg.id != null)) {
f = this.call_callbacks[mesg.id];
if (f != null) {
delete this.call_callbacks[mesg.id];
f(void 0, mesg);
return;
}
}
if (mesg.id != null) {
this._messages.being_handled[mesg.id] = new Date();
}
handler = this[`mesg_${mesg.event}`];
if (handler != null) {
handler(mesg);
return mesg_from_client_total.labels(`${mesg.event}`).inc(1);
} else {
this.push_to_client(message.error({
error: `Hub does not know how to handle a '${mesg.event}' event.`,
id: mesg.id
}));
if (mesg.event === 'get_all_activity') {
dbg(`ignoring all further messages from old client=${this.id}`);
return this._ignore_client = true;
}
}
}
mesg_ping(mesg) {
boundMethodCheck(this, ref);
return this.push_to_client(message.pong({
id: mesg.id,
now: new Date()
}));
}
mesg_create_account(mesg) {
boundMethodCheck(this, ref);
if (this._opts.personal) {
this.error_to_client({
id: mesg.id,
error: "account creation not allowed on personal server"
});
return;
}
return create_account({
client: this,
mesg: mesg,
database: this.database,
host: this._opts.host,
port: this._opts.port,
sign_in: this.conn != null // browser clients have a websocket conn
});
}
mesg_delete_account(mesg) {
boundMethodCheck(this, ref);
return delete_account({
client: this,
mesg: mesg,
database: this.database
});
}
mesg_sign_in(mesg) {
boundMethodCheck(this, ref);
return sign_in.sign_in({
client: this,
mesg: mesg,
logger: this.logger,
database: this.database,
host: this._opts.host,
port: this._opts.port
});
}
mesg_sign_in_using_auth_token(mesg) {
boundMethodCheck(this, ref);
return sign_in.sign_in_using_auth_token({
client: this,
mesg: mesg,
logger: this.logger,
database: this.database,
host: this._opts.host,
port: this._opts.port
});
}
mesg_sign_out(mesg) {
boundMethodCheck(this, ref);
if (this.account_id == null) {
this.push_to_client(message.error({
id: mesg.id,
error: "not signed in"
}));
return;
}
if (mesg.everywhere) {
// invalidate all remember_me cookies
this.database.invalidate_all_remember_me({
account_id: this.account_id
});
}
this.signed_out(); // deletes @account_id... so must be below database call above
// invalidate the remember_me on this browser
return this.invalidate_remember_me({
cb: (error) => {
this.dbg('mesg_sign_out')(`signing out: ${mesg.id}, ${error}`);
if (error) {
return this.push_to_client(message.error({
id: mesg.id,
error: error
}));
} else {
return this.push_to_client(message.signed_out({
id: mesg.id
}));
}
}
});
}
mesg_change_password(mesg) {
boundMethodCheck(this, ref);
return password.change_password({
mesg: mesg,
account_id: this.account_id,
ip_address: this.ip_address,
database: this.database,
cb: (err) => {
return this.push_to_client(message.changed_password({
id: mesg.id,
error: err
}));
}
});
}
mesg_forgot_password(mesg) {
boundMethodCheck(this, ref);
return password.forgot_password({
mesg: mesg,
ip_address: this.ip_address,
database: this.database,
cb: (err) => {
return this.push_to_client(message.forgot_password_response({
id: mesg.id,
error: err
}));
}
});
}
mesg_reset_forgot_password(mesg) {
boundMethodCheck(this, ref);
return password.reset_forgot_password({
mesg: mesg,
database: this.database,
cb: (err) => {
return this.push_to_client(message.reset_forgot_password_response({
id: mesg.id,
error: err
}));
}
});
}
mesg_change_email_address(mesg) {
boundMethodCheck(this, ref);
return password.change_email_address({
mesg: mesg,
account_id: this.account_id,
ip_address: this.ip_address,
database: this.database,
logger: this.logger,
cb: (err) => {
return this.push_to_client(message.changed_email_address({
id: mesg.id,
error: err
}));
}
});
}
mesg_send_verification_email(mesg) {
var ref1;
boundMethodCheck(this, ref);
auth = require('./auth');
return auth.verify_email_send_token({
account_id: mesg.account_id,
only_verify: (ref1 = mesg.only_verify) != null ? ref1 : true,
database: this.database,
cb: (err) => {
if (err) {
return this.error_to_client({
id: mesg.id,
error: err
});
} else {
return this.success_to_client({
id: mesg.id
});
}
}
});
}
mesg_unlink_passport(mesg) {
boundMethodCheck(this, ref);
if (this.account_id == null) {
return this.error_to_client({
id: mesg.id,
error: "must be logged in"
});
} else {
return this.database.delete_passport({
account_id: this.account_id,
strategy: mesg.strategy,
id: mesg.id,
cb: (err) => {
if (err) {
return this.error_to_client({
id: mesg.id,
error: err
});
} else {
return this.success_to_client({
id: mesg.id
});
}
}
});
}
}
get_groups(cb) {
boundMethodCheck(this, ref);
// see note above about our "infinite caching". Maybe a bad idea.
if (this.groups != null) {
if (typeof cb === "function") {
cb(void 0, this.groups);
}
return;
}
return this.database.get_account({
columns: ['groups'],
account_id: this.account_id,
cb: (err, r) => {
if (err) {
return typeof cb === "function" ? cb(err) : void 0;
} else {
this.groups = r['groups'];
return typeof cb === "function" ? cb(void 0, this.groups) : void 0;
}
}
});
}
mesg_log_client_error(mesg) {
boundMethodCheck(this, ref);
this.dbg('mesg_log_client_error')(mesg.error);
if (mesg.type == null) {
mesg.type = "error";
}
if (mesg.error == null) {
mesg.error = "error";
}
return this.database.log_client_error({
event: mesg.type,
error: mesg.error,
account_id: this.account_id,
cb: (err) => {
if (mesg.id == null) {
return;
}
if (err) {
return this.error_to_client({
id: mesg.id,
error: err
});
} else {
return this.success_to_client({
id: mesg.id
});
}
}
});
}
mesg_webapp_error(mesg) {
boundMethodCheck(this, ref);
this.dbg('mesg_webapp_error')(mesg.msg);
mesg = misc.copy_without(mesg, 'event');
mesg.account_id = this.account_id;
return this.database.webapp_error(mesg);
}
get_project(mesg, permission, cb) {
/*
How to use this: Either call the callback with the project, or if an error err
occured, call @error_to_client(id:mesg.id, error:err) and *NEVER*
call the callback. This function is meant to be used in a bunch
of the functions below for handling requests.
mesg -- must have project_id field
permission -- must be "read" or "write"
cb(err, project)
*NOTE*: on failure, if mesg.id is defined, then client will receive
an error message; the function calling get_project does *NOT*
have to send the error message back to the client!
*/
var dbg, err, key, project, ref1;
boundMethodCheck(this, ref);
dbg = this.dbg('get_project');
err = void 0;
if (mesg.project_id == null) {
err = `mesg must have project_id attribute -- ${to_safe_str(mesg)}`;
} else if (this.account_id == null) {
err = "user must be signed in before accessing projects";
}
if (err) {
if (mesg.id != null) {
this.error_to_client({
id: mesg.id,
error: err
});
}
cb(err);
return;
}
key = mesg.project_id + permission;
project = (ref1 = this._project_cache) != null ? ref1[key] : void 0;
if (project != null) {
// Use the cached project so we don't have to re-verify authentication
// for the user again below, which
// is very expensive. This cache does expire, in case user
// is kicked out of the project.
cb(void 0, project);
return;
}
dbg();
return async.series([
(cb) => {
switch (permission) {
case 'read':
return access.user_has_read_access_to_project({
project_id: mesg.project_id,
account_id: this.account_id,
account_groups: this.groups,
database: this.database,
cb: (err,
result) => {
if (err) {
return cb(`Internal error determining user permission -- ${err}`);
} else if (!result) {
return cb(`User ${this.account_id} does not have read access to project ${mesg.project_id}`);
} else {
// good to go
return cb();
}
}
});
case 'write':
return access.user_has_write_access_to_project({
database: this.database,
project_id: mesg.project_id,
account_groups: this.groups,
account_id: this.account_id,
cb: (err,
result) => {
if (err) {
return cb(`Internal error determining user permission -- ${err}`);
} else if (!result) {
return cb(`User ${this.account_id} does not have write access to project ${mesg.project_id}`);
} else {
// good to go
return cb();
}
}
});
default: