Path: blob/master/src/packages/database/postgres-user-queries.coffee
5688 views
#########################################################################1# This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2# License: MS-RSL – see LICENSE.md for details3#########################################################################45"""6User (and project) client queries78COPYRIGHT : (c) 2017 SageMath, Inc.9LICENSE : MS-RSL10"""1112MAX_CHANGEFEEDS_PER_CLIENT = 20001314# Reject all patches that have timestamp that is more than 3 minutes in the future.15MAX_PATCH_FUTURE_MS = 1000*60*31617EventEmitter = require('events')18async = require('async')19lodash = require('lodash')2021{one_result, all_results, count_result, pg_type, quote_field} = require('./postgres-base')2223{UserQueryQueue} = require('./postgres-user-query-queue')2425{defaults} = misc = require('@cocalc/util/misc')26required = defaults.required2728{SCHEMA, OPERATORS, isToOperand} = require('@cocalc/util/schema')29{queryIsCmp, userGetQueryFilter} = require("./user-query/user-get-query")3031{updateRetentionData} = require('./postgres/retention')32{sanitizeManageUsersOwnerOnly} = require('./postgres/project/manage-users-owner-only')33{sanitizeUserSetQueryProjectUsers} = require('./postgres/project/user-set-query-project-users')3435{ checkProjectName } = require("@cocalc/util/db-schema/name-rules");36{callback2} = require('@cocalc/util/async-utils')373839exports.extend_PostgreSQL = (ext) -> class PostgreSQL extends ext40# Cancel all queued up queries by the given client41cancel_user_queries: (opts) =>42opts = defaults opts,43client_id : required44@_user_query_queue?.cancel_user_queries(opts)4546user_query: (opts) =>47opts = defaults opts,48client_id : undefined # if given, uses to control number of queries at once by one client.49priority : undefined # (NOT IMPLEMENTED) priority for this query (an integer [-10,...,19] like in UNIX)50account_id : undefined51project_id : undefined52query : required53options : []54changes : undefined55cb : undefined5657if opts.account_id?58# Check for "sudo" by admin to query as a different user, which is done by specifying59# options = [..., {account_id:'uuid'}, ...].60for x in opts.options61if x.account_id?62# Check user is an admin, then change opts.account_id63@get_account64columns : ['groups']65account_id : opts.account_id66cb : (err, r) =>67if err68opts.cb?(err)69else if r['groups']? and 'admin' in r['groups']70opts.account_id = x.account_id71opts.options = (y for y in opts.options when not y['account_id']?)72# now do query with new opts and options not including account_id sudo.73@user_query(opts)74else75opts.cb?('user must be admin to sudo')76return7778if not opts.client_id?79# No client_id given, so do not use query queue.80delete opts.priority81delete opts.client_id82@_user_query(opts)83return8485if not @_user_query_queue?86o =87do_query : @_user_query88dbg : @_dbg('user_query_queue')89concurrent : @concurrent90@_user_query_queue ?= new UserQueryQueue(o)9192@_user_query_queue.user_query(opts)9394_user_query: (opts) =>95opts = defaults opts,96account_id : undefined97project_id : undefined98query : required99options : [] # used for initial query; **IGNORED** by changefeed!;100# - Use [{set:true}] or [{set:false}] to force get or set query101# - For a set query, use {delete:true} to delete instead of set. This is the only way102# to delete a record, and won't work unless delete:true is set in the schema103# for the table to explicitly allow deleting.104changes : undefined # id of change feed105cb : undefined # cb(err, result) # WARNING -- this *will* get called multiple times when changes is true!106id = misc.uuid().slice(0,6)107dbg = @_dbg("_user_query(id=#{id})")108dbg(misc.to_json(opts.query))109if misc.is_array(opts.query)110dbg('array query instead')111@_user_query_array(opts)112return113114subs =115'{account_id}' : opts.account_id116'{project_id}' : opts.project_id117'{now}' : new Date()118119if opts.changes?120changes =121id : opts.changes122cb : opts.cb123124v = misc.keys(opts.query)125if v.length > 1126dbg("FATAL no key")127opts.cb?('FATAL: must specify exactly one key in the query')128return129table = v[0]130query = opts.query[table]131if misc.is_array(query)132if query.length > 1133dbg("FATAL not implemented")134opts.cb?("FATAL: array of length > 1 not yet implemented")135return136multi = true137query = query[0]138else139multi = false140is_set_query = undefined141if opts.options?142if not misc.is_array(opts.options)143dbg("FATAL options")144opts.cb?("FATAL: options (=#{misc.to_json(opts.options)}) must be an array")145return146for x in opts.options147if x.set?148is_set_query = !!x.set149options = (x for x in opts.options when not x.set?)150else151options = []152153if misc.is_object(query)154query = misc.deep_copy(query)155misc.obj_key_subs(query, subs)156if not is_set_query?157is_set_query = not misc.has_null_leaf(query)158if is_set_query159dbg("do a set query")160if changes161dbg("FATAL: changefeed")162opts.cb?("FATAL: changefeeds only for read queries")163return164if not opts.account_id? and not opts.project_id?165dbg("FATAL: anon set")166opts.cb?("FATAL: no anonymous set queries")167return168dbg("user_set_query")169@user_set_query170account_id : opts.account_id171project_id : opts.project_id172table : table173query : query174options : opts.options175cb : (err, x) =>176dbg("returned #{err}")177opts.cb?(err, {"#{table}":x})178else179# do a get query180if changes and not multi181dbg("FATAL: changefeed multi")182opts.cb?("FATAL: changefeeds only implemented for multi-document queries")183return184185if changes186err = @_inc_changefeed_count(opts.account_id, opts.project_id, table, changes.id)187if err188dbg("err changefeed count -- #{err}")189opts.cb?(err)190return191192dbg("user_get_query")193@user_get_query194account_id : opts.account_id195project_id : opts.project_id196table : table197query : query198options : options199multi : multi200changes : changes201cb : (err, x) =>202dbg("returned #{err}")203if err and changes204# didn't actually make the changefeed, so don't count it.205@_dec_changefeed_count(changes.id, table)206opts.cb?(err, if not err then {"#{table}":x})207else208dbg("FATAL - invalid table")209opts.cb?("FATAL: invalid user_query of '#{table}' -- query must be an object")210211###212TRACK CHANGEFEED COUNTS213214_inc and dec below are evidently broken, in that it's CRITICAL that they match up exactly, or users will be215locked out until they just happen to switch to another hub with different tracking, which is silly.216217TODO: DISABLED FOR NOW!218###219220# Increment a count of the number of changefeeds by a given client so we can cap it.221_inc_changefeed_count: (account_id, project_id, table, changefeed_id) =>222return223client_name = "#{account_id}-#{project_id}"224cnt = @_user_get_changefeed_counts ?= {}225ids = @_user_get_changefeed_id_to_user ?= {}226if not cnt[client_name]?227cnt[client_name] = 1228else if cnt[client_name] >= MAX_CHANGEFEEDS_PER_CLIENT229return "user may create at most #{MAX_CHANGEFEEDS_PER_CLIENT} changefeeds; please close files, refresh browser, restart project"230else231# increment before successfully making get_query to prevent huge bursts causing trouble!232cnt[client_name] += 1233@_dbg("_inc_changefeed_count(table='#{table}')")("{#{client_name}:#{cnt[client_name]} ...}")234ids[changefeed_id] = client_name235return false236237# Corresponding decrement of count of the number of changefeeds by a given client.238_dec_changefeed_count: (id, table) =>239return240client_name = @_user_get_changefeed_id_to_user[id]241if client_name?242@_user_get_changefeed_counts?[client_name] -= 1243delete @_user_get_changefeed_id_to_user[id]244cnt = @_user_get_changefeed_counts245if table?246t = "(table='#{table}')"247else248t = ""249@_dbg("_dec_changefeed_count#{t}")("counts={#{client_name}:#{cnt[client_name]} ...}")250251# Handle user_query when opts.query is an array. opts below are as for user_query.252_user_query_array: (opts) =>253if opts.changes and opts.query.length > 1254opts.cb?("FATAL: changefeeds only implemented for single table")255return256result = []257f = (query, cb) =>258@user_query259account_id : opts.account_id260project_id : opts.project_id261query : query262options : opts.options263cb : (err, x) =>264result.push(x); cb(err)265async.mapSeries(opts.query, f, (err) => opts.cb(err, result))266267user_query_cancel_changefeed: (opts) =>268opts = defaults opts,269id : required270cb : undefined # not really asynchronous271dbg = @_dbg("user_query_cancel_changefeed(id='#{opts.id}')")272feed = @_changefeeds?[opts.id]273if feed?274dbg("actually canceling feed")275@_dec_changefeed_count(opts.id)276delete @_changefeeds[opts.id]277feed.close()278else279dbg("already canceled before (no such feed)")280opts.cb?()281282_user_get_query_columns: (query, remove_from_query) =>283v = misc.keys(query)284if remove_from_query?285# If remove_from_query is specified it should be an array of strings286# and we do not includes these in what is returned.287v = lodash.difference(v, remove_from_query)288return v289290_require_is_admin: (account_id, cb) =>291if not account_id?292cb("FATAL: user must be an admin")293return294@is_admin295account_id : account_id296cb : (err, is_admin) =>297if err298cb(err)299else if not is_admin300cb("FATAL: user must be an admin")301else302cb()303304# Ensure that each project_id in project_ids is such that the account is in one of the given305# groups for the project, or that the account is an admin. If not, cb(err).306_require_project_ids_in_groups: (account_id, project_ids, groups, cb) =>307s = {"#{account_id}": true}308require_admin = false309@_query310query : "SELECT project_id, users#>'{#{account_id}}' AS user FROM projects"311where : "project_id = ANY($)":project_ids312cache : true313cb : all_results (err, x) =>314if err315cb(err)316else317known_project_ids = {} # we use this to ensure that each of the given project_ids exists.318for p in x319known_project_ids[p.project_id] = true320if p.user?.group not in groups321require_admin = true322# If any of the project_ids don't exist, reject the query.323for project_id in project_ids324if not known_project_ids[project_id]325cb("FATAL: unknown project_id '#{misc.trunc(project_id,100)}'")326return327if require_admin328@_require_is_admin(account_id, cb)329else330cb()331332_query_parse_options: (options) =>333r = {}334for x in options335for name, value of x336switch name337when 'only_changes'338r.only_changes = !!value339when 'limit'340r.limit = parseInt(value)341when 'slice'342r.slice = value343when 'order_by'344if value[0] == '-'345value = value.slice(1) + " DESC "346if r.order_by347r.order_by = r.order_by + ', ' + value348else349r.order_by = value350when 'delete'351null352# ignore delete here - is parsed elsewhere353when 'heartbeat'354@_dbg("_query_parse_options")("TODO/WARNING -- ignoring heartbeat option from old client")355else356r.err = "unknown option '#{name}'"357# Guard rails: no matter what, all queries are capped with a limit of 100000.358# TODO: If somehow somebody has, e.g., more than 100K projects, or maybe more359# than 100K edits of a single file, they could hit this and not realize it. I360# had this set at 1000 for a few minutes and it caused me to randomly not have361# some of my projects.362MAX_LIMIT = 100000363try364if not isFinite(r.limit)365r.limit = MAX_LIMIT366else if r.limit > MAX_LIMIT367r.limit = MAX_LIMIT368catch369r.limit = MAX_LIMIT370return r371372###373SET QUERIES374###375_parse_set_query_opts: (opts) =>376r = {}377378if opts.project_id?379dbg = r.dbg = @_dbg("user_set_query(project_id='#{opts.project_id}', table='#{opts.table}')")380else if opts.account_id?381dbg = r.dbg = @_dbg("user_set_query(account_id='#{opts.account_id}', table='#{opts.table}')")382else383return {err:"FATAL: account_id or project_id must be specified to set query on table='#{opts.table}'"}384385if not SCHEMA[opts.table]?386return {err:"FATAL: table '#{opts.table}' does not exist"}387388dbg(misc.to_json(opts.query))389390if opts.options391dbg("options=#{misc.to_json(opts.options)}")392393r.query = misc.copy(opts.query)394r.table = opts.table395r.db_table = SCHEMA[opts.table].virtual ? opts.table396r.account_id = opts.account_id397r.project_id = opts.project_id398399s = SCHEMA[opts.table]400401if opts.account_id?402r.client_query = s?.user_query403else404r.client_query = s?.project_query405406if not r.client_query?.set?.fields?407return {err:"FATAL: user set queries not allowed for table '#{opts.table}'"}408409if not @_mod_fields(opts.query, r.client_query)410dbg("shortcut -- no fields will be modified, so nothing to do")411return412413for field in misc.keys(r.client_query.set.fields)414if r.client_query.set.fields[field] == undefined415return {err: "FATAL: user set query not allowed for #{opts.table}.#{field}"}416val = r.client_query.set.fields[field]417418if typeof(val) == 'function'419try420r.query[field] = val(r.query, @)421catch err422return {err:"FATAL: error setting '#{field}' -- #{err}"}423else424switch val425when 'account_id'426if not r.account_id?427return {err: "FATAL: account_id must be specified -- make sure you are signed in"}428r.query[field] = r.account_id429when 'project_id'430if not r.project_id?431return {err: "FATAL: project_id must be specified"}432r.query[field] = r.project_id433when 'time_id'434r.query[field] = uuid.v1()435when 'project_write'436if not r.query[field]?437return {err: "FATAL: must specify #{opts.table}.#{field}"}438r.require_project_ids_write_access = [r.query[field]]439when 'project_owner'440if not r.query[field]?441return {err:"FATAL: must specify #{opts.table}.#{field}"}442r.require_project_ids_owner = [r.query[field]]443444if r.client_query.set.admin445r.require_admin = true446447r.primary_keys = @_primary_keys(r.db_table)448449r.json_fields = @_json_fields(r.db_table, r.query)450451for k, v of r.query452if k in r.primary_keys453continue454if r.client_query?.set?.fields?[k] != undefined455continue456if s.admin_query?.set?.fields?[k] != undefined457r.require_admin = true458continue459return {err: "FATAL: changing #{r.table}.#{k} not allowed"}460461# HOOKS which allow for running arbitrary code in response to462# user set queries. In each case, new_val below is only the part463# of the object that the user requested to change.464465# 0. CHECK: Runs before doing any further processing; has callback, so this466# provides a generic way to quickly check whether or not this query is allowed467# for things that can't be done declaratively. The check_hook can also468# mutate the obj (the user query), e.g., to enforce limits on input size.469r.check_hook = r.client_query.set.check_hook470471# 1. BEFORE: If before_change is set, it is called with input472# (database, old_val, new_val, account_id, cb)473# before the actual change to the database is made.474r.before_change_hook = r.client_query.set.before_change475476# 2. INSTEAD OF: If instead_of_change is set, then instead_of_change_hook477# is called with input478# (database, old_val, new_val, account_id, cb)479# *instead* of actually doing the update/insert to480# the database. This makes it possible to run arbitrary481# code whenever the user does a certain type of set query.482# Obviously, if that code doesn't set the new_val in the483# database, then new_val won't be the new val.484r.instead_of_change_hook = r.client_query.set.instead_of_change485486# 3. AFTER: If set, the on_change_hook is called with487# (database, old_val, new_val, account_id, cb)488# after everything the database has been modified.489r.on_change_hook = r.client_query.set.on_change490491# 4. instead of query492r.instead_of_query = r.client_query.set.instead_of_query493494#dbg("on_change_hook=#{on_change_hook?}, #{misc.to_json(misc.keys(client_query.set))}")495496# Set the query options -- order doesn't matter for set queries (unlike for get), so we497# just merge the options into a single dictionary.498# NOTE: As I write this, there is just one supported option: {delete:true}.499r.options = {}500if r.client_query.set.options?501for x in r.client_query.set.options502for y, z of x503r.options[y] = z504if opts.options?505for x in opts.options506for y, z of x507r.options[y] = z508dbg("options = #{misc.to_json(r.options)}")509510if r.options.delete and not r.client_query.set.delete511# delete option is set, but deletes aren't explicitly allowed on this table. ERROR.512return {err: "FATAL: delete from #{r.table} not allowed"}513514return r515516_user_set_query_enforce_requirements: (r, cb) =>517async.parallel([518(cb) =>519if r.require_admin520@_require_is_admin(r.account_id, cb)521else522cb()523(cb) =>524if r.require_project_ids_write_access?525if r.project_id?526err = undefined527for x in r.require_project_ids_write_access528if x != r.project_id529err = "FATAL: can only query same project"530break531cb(err)532else533@_require_project_ids_in_groups(r.account_id, r.require_project_ids_write_access,\534['owner', 'collaborator'], cb)535else536cb()537(cb) =>538if r.require_project_ids_owner?539@_require_project_ids_in_groups(r.account_id, r.require_project_ids_owner,\540['owner'], cb)541else542cb()543], cb)544545_user_set_query_where: (r) =>546where = {}547for primary_key in @_primary_keys(r.db_table)548value = r.query[primary_key]549if SCHEMA[r.db_table].fields[primary_key].noCoerce550where["#{primary_key}=$"] = value551else552type = pg_type(SCHEMA[r.db_table].fields[primary_key])553if type == 'TIMESTAMP' and not misc.is_date(value)554# Javascript is better at parsing its own dates than PostgreSQL555# isNaN test so NOW(), etc. work still556x = new Date(value)557if not isNaN(x)558value = x559where["#{primary_key}=$::#{type}"] = value560return where561562_user_set_query_values: (r) =>563values = {}564s = SCHEMA[r.db_table]565for key, value of r.query566type = pg_type(s?.fields?[key])567if value? and type? and not s?.fields?[key]?.noCoerce568if type == 'TIMESTAMP' and not misc.is_date(value)569# (as above) Javascript is better at parsing its own dates than PostgreSQL570x = new Date(value)571if not isNaN(x)572value = x573values["#{key}::#{type}"] = value574else575values[key] = value576return values577578_user_set_query_hooks_prepare: (r, cb) =>579if r.on_change_hook? or r.before_change_hook? or r.instead_of_change_hook?580for primary_key in r.primary_keys581if not r.query[primary_key]?582# this is fine -- it just means the old_val isn't defined.583# this can happen, e.g., when creating a new object with a primary key that is a generated id.584cb()585return586# get the old value before changing it587# TODO: optimization -- can we restrict columns below?588@_query589query : "SELECT * FROM #{r.db_table}"590where : @_user_set_query_where(r)591cb : one_result (err, x) =>592r.old_val = x; cb(err)593else594cb()595596_user_query_set_count: (r, cb) =>597@_query598query : "SELECT COUNT(*) FROM #{r.db_table}"599where : @_user_set_query_where(r)600cb : count_result(cb)601602_user_query_set_delete: (r, cb) =>603@_query604query : "DELETE FROM #{r.db_table}"605where : @_user_set_query_where(r)606cb : cb607608_user_set_query_conflict: (r) =>609return r.primary_keys610611_user_query_set_upsert: (r, cb) =>612# r.dbg("_user_query_set_upsert #{JSON.stringify(r.query)}")613@_query614query : "INSERT INTO #{r.db_table}"615values : @_user_set_query_values(r)616conflict : @_user_set_query_conflict(r)617cb : cb618619# Record is already in DB, so we update it:620# this function handles a case that involves both621# a jsonb_merge and an update.622_user_query_set_upsert_and_jsonb_merge: (r, cb) =>623jsonb_merge = {}624for k of r.json_fields625v = r.query[k]626if v?627jsonb_merge[k] = v628set = {}629for k, v of r.query630if k not in r.primary_keys and not jsonb_merge[k]?631set[k] = v632@_query633query : "UPDATE #{r.db_table}"634jsonb_merge : jsonb_merge635set : set636where : @_user_set_query_where(r)637cb : cb638639_user_set_query_main_query: (r, cb) =>640r.dbg("_user_set_query_main_query")641642if not r.client_query.set.allow_field_deletes643# allow_field_deletes not set, so remove any null/undefined644# fields from the query645for key of r.query646if not r.query[key]?647delete r.query[key]648649if r.options.delete650for primary_key in r.primary_keys651if not r.query[primary_key]?652cb("FATAL: delete query must set primary key")653return654r.dbg("delete based on primary key")655@_user_query_set_delete(r, cb)656return657if r.instead_of_change_hook?658r.instead_of_change_hook(@, r.old_val, r.query, r.account_id, cb)659else660if misc.len(r.json_fields) == 0661# easy case -- there are no jsonb merge fields; just do an upsert.662@_user_query_set_upsert(r, cb)663return664# HARD CASE -- there are json_fields... so we are doing an insert665# if the object isn't already in the database, and an update666# if it is. This is ugly because I don't know how to do both667# a JSON merge as an upsert.668cnt = undefined # will equal number of records having the primary key (so 0 or 1)669async.series([670(cb) =>671@_user_query_set_count r, (err, n) =>672cnt = n; cb(err)673(cb) =>674r.dbg("do the set query")675if cnt == 0676# Just insert (do as upsert to avoid error in case of race)677@_user_query_set_upsert(r, cb)678else679# Do as an update -- record is definitely already in db since cnt > 0.680# This would fail in the unlikely (but possible) case that somebody deletes681# the record between the above count and when we do the UPDATE.682# Using a transaction could avoid this.683# Maybe such an error is reasonable and it's good to report it as such.684@_user_query_set_upsert_and_jsonb_merge(r, cb)685], cb)686687user_set_query: (opts) =>688opts = defaults opts,689account_id : undefined690project_id : undefined691table : required692query : required693options : undefined # options=[{delete:true}] is the only supported nontrivial option here.694cb : required # cb(err)695696# TODO: it would be nice to return the primary key part of the created object on creation.697# That's not implemented and will be somewhat nontrivial, and will use the RETURNING clause698# of postgres's INSERT - https://www.postgresql.org/docs/current/sql-insert.html699700if @is_standby701opts.cb("set queries against standby not allowed")702return703r = @_parse_set_query_opts(opts)704705# Only uncomment for debugging -- too big/verbose/dangerous706# r.dbg("parsed query opts = #{JSON.stringify(r)}")707708if not r? # nothing to do709opts.cb()710return711if r.err712opts.cb(r.err)713return714715async.series([716(cb) =>717@_user_set_query_enforce_requirements(r, cb)718(cb) =>719if r.check_hook?720r.check_hook(@, r.query, r.account_id, r.project_id, cb)721else722cb()723(cb) =>724@_user_set_query_hooks_prepare(r, cb)725(cb) =>726if r.before_change_hook?727r.before_change_hook @, r.old_val, r.query, r.account_id, (err, stop) =>728r.done = stop729cb(err)730else731cb()732(cb) =>733if r.done734cb()735return736if r.instead_of_query?737opts1 = misc.copy_without(opts, ['cb', 'changes', 'table'])738r.instead_of_query(@, opts1, cb)739else740@_user_set_query_main_query(r, cb)741(cb) =>742if r.done743cb()744return745if r.on_change_hook?746r.on_change_hook(@, r.old_val, r.query, r.account_id, cb)747else748cb()749], (err) => opts.cb(err))750751# mod_fields counts the fields in query that might actually get modified752# in the database when we do the query; e.g., account_id won't since it gets753# filled in with the user's account_id, and project_write won't since it must754# refer to an existing project. We use mod_field **only** to skip doing755# no-op queries. It's just an optimization.756_mod_fields: (query, client_query) =>757for field in misc.keys(query)758if client_query.set.fields[field] not in ['account_id', 'project_write']759return true760return false761762_user_get_query_json_timestamps: (obj, fields) =>763# obj is an object returned from the database via a query764# Postgres JSONB doesn't support timestamps, so we convert765# every json leaf node of obj that looks like JSON of a timestamp766# to a Javascript Date.767for k, v of obj768if fields[k]769obj[k] = misc.fix_json_dates(v, fields[k])770771# fill in the default values for obj using the client_query spec.772_user_get_query_set_defaults: (client_query, obj, fields) =>773if not misc.is_array(obj)774obj = [obj]775else if obj.length == 0776return777s = client_query?.get?.fields ? {}778for k in fields779v = s[k]780if v?781# k is a field for which a default value (=v) is provided in the schema782for x in obj783# For each obj pulled from the database that is defined...784if x?785# We check to see if the field k was set on that object.786y = x[k]787if not y?788# It was NOT set, so we deep copy the default value for the field k.789x[k] = misc.deep_copy(v)790else if typeof(v) == 'object' and typeof(y) == 'object' and not misc.is_array(v)791# y *is* defined and is an object, so we merge in the provided defaults.792for k0, v0 of v793if not y[k0]?794y[k0] = v0795796_user_set_query_project_users: (obj, account_id) =>797return sanitizeUserSetQueryProjectUsers(obj, account_id)798799_user_set_query_project_manage_users_owner_only: (obj, account_id) =>800# This hook is called from the schema functional substitution to validate801# the manage_users_owner_only flag. This must be synchronous - async validation802# (permission checks) is done in the check_hook instead.803# Just do basic type validation and sanitization here804return sanitizeManageUsersOwnerOnly(obj.manage_users_owner_only)805806project_action: (opts) =>807opts = defaults opts,808project_id : required809action_request : required # action is object {action:?, time:?}810cb : required811if opts.action_request.action == 'test'812# used for testing -- shouldn't trigger anything to happen.813opts.cb()814return815dbg = @_dbg("project_action(project_id='#{opts.project_id}',action_request=#{misc.to_json(opts.action_request)})")816dbg()817project = undefined818action_request = misc.copy(opts.action_request)819set_action_request = (cb) =>820dbg("set action_request to #{misc.to_json(action_request)}")821@_query822query : "UPDATE projects"823where : 'project_id = $::UUID':opts.project_id824jsonb_set : {action_request : action_request}825cb : cb826async.series([827(cb) =>828action_request.started = new Date()829set_action_request(cb)830(cb) =>831dbg("get project")832try833project = await @projectControl(opts.project_id)834cb()835catch err836cb(err)837(cb) =>838dbg("doing action")839try840switch action_request.action841when 'restart'842await project.restart()843when 'stop'844await project.stop()845when 'start'846await project.start()847else848throw Error("FATAL: action '#{opts.action_request.action}' not implemented")849cb()850catch err851cb(err)852], (err) =>853if err854action_request.err = err855action_request.finished = new Date()856dbg("finished!")857set_action_request(opts.cb)858)859860# This hook is called *before* the user commits a change to a project in the database861# via a user set query.862# TODO: Add a pre-check here as well that total upgrade isn't going to be exceeded.863# This will avoid a possible subtle edge case if user is cheating and always somehow864# crashes server...?865_user_set_query_project_change_before: (old_val, new_val, account_id, cb) =>866#dbg = @_dbg("_user_set_query_project_change_before #{account_id}, #{misc.to_json(old_val)} --> #{misc.to_json(new_val)}")867# I've seen MASSIVE OUTPUT from this, e.g., when setting avatar.868dbg = @_dbg("_user_set_query_project_change_before #{account_id}")869dbg()870871if new_val?.name and (new_val?.name != old_val?.name)872# Changing or setting the name of the project to something nontrivial.873try874checkProjectName(new_val.name);875catch err876cb(err.toString())877return878if new_val.name879# Setting name to something nontrivial, so we must check uniqueness880# among all projects this user owns.881result = await callback2 @_query,882query : 'SELECT COUNT(*) FROM projects'883where :884"users#>>'{#{account_id},group}' = $::TEXT" : 'owner'885"project_id != $::UUID" : new_val.project_id886"LOWER(name) = $::TEXT":new_val.name.toLowerCase()887if result.rows[0].count > 0888cb("There is already a project with the same owner as this project and name='#{new_val.name}'. Names are not case sensitive.")889return890# A second constraint is that only the project owner can change the project name.891result = await callback2 @_query,892query : 'SELECT COUNT(*) FROM projects'893where :894"users#>>'{#{account_id},group}' = $::TEXT" : 'owner'895"project_id = $::UUID" : new_val.project_id896if result.rows[0].count == 0897cb("Only the owner of the project can currently change the project name.")898return899900if new_val?.manage_users_owner_only? and new_val.manage_users_owner_only != old_val?.manage_users_owner_only901# Permission is enforced in the set-field interceptor; nothing to do here.902# Leaving this block for clarity and to avoid silent bypass if future callers903# modify manage_users_owner_only via another path.904dbg("manage_users_owner_only change requested")905906if new_val?.action_request? and JSON.stringify(new_val.action_request.time) != JSON.stringify(old_val?.action_request?.time)907# Requesting an action, e.g., save, restart, etc.908dbg("action_request -- #{misc.to_json(new_val.action_request)}")909#910# WARNING: Above, we take the difference of times below, since != doesn't work as we want with911# separate Date objects, as it will say equal dates are not equal. Example:912# coffee> x = JSON.stringify(new Date()); {from_json}=require('misc'); a=from_json(x); b=from_json(x); [a!=b, a-b]913# [ true, 0 ]914915# Launch the action -- success or failure communicated back to all clients through changes to state.916# Also, we don't have to worry about permissions here; that this function got called at all means917# the user has write access to the projects table entry with given project_id, which gives them permission918# to do any action with the project.919@project_action920project_id : new_val.project_id921action_request : misc.copy_with(new_val.action_request, ['action', 'time'])922cb : (err) =>923dbg("action_request #{misc.to_json(new_val.action_request)} completed -- #{err}")924# true means -- do nothing further. We don't want to the user to925# set this same thing since we already dealt with it properly.926cb(err, true)927return928929if not new_val.users? # not changing users930cb(); return931old_val = old_val?.users ? {}932new_val = new_val?.users ? {}933for id in misc.keys(old_val).concat(new_val)934if account_id != id935# make sure user doesn't change anybody else's allocation936if not lodash.isEqual(old_val?[id]?.upgrades, new_val?[id]?.upgrades)937err = "FATAL: user '#{account_id}' tried to change user '#{id}' allocation toward a project"938dbg(err)939cb(err)940return941cb()942943# This hook is called *after* the user commits a change to a project in the database944# via a user set query. It could undo changes the user isn't allowed to make, which945# might require doing various async calls, or take actions (e.g., setting quotas,946# starting projects, etc.).947_user_set_query_project_change_after: (old_val, new_val, account_id, cb) =>948dbg = @_dbg("_user_set_query_project_change_after #{account_id}, #{misc.to_json(old_val)} --> #{misc.to_json(new_val)}")949dbg()950old_upgrades = old_val.users?[account_id]?.upgrades951new_upgrades = new_val.users?[account_id]?.upgrades952if new_upgrades? and not lodash.isEqual(old_upgrades, new_upgrades)953dbg("upgrades changed for #{account_id} from #{misc.to_json(old_upgrades)} to #{misc.to_json(new_upgrades)}")954project = undefined955async.series([956(cb) =>957@ensure_user_project_upgrades_are_valid958account_id : account_id959cb : cb960(cb) =>961if not @projectControl?962cb()963else964dbg("get project")965try966project = await @projectControl(new_val.project_id)967cb()968catch err969cb(err)970(cb) =>971if not project?972cb()973else974dbg("determine total quotas and apply")975try976await project.setAllQuotas()977cb()978catch err979cb(err)980], cb)981else982cb()983984###985GET QUERIES986###987988# Make any functional substitutions defined by the schema.989# This may mutate query in place.990_user_get_query_functional_subs: (query, fields) =>991if fields?992for field, val of fields993if typeof(val) == 'function'994query[field] = val(query, @)995996_parse_get_query_opts: (opts) =>997if opts.changes? and not opts.changes.cb?998return {err: "FATAL: user_get_query -- if opts.changes is specified, then opts.changes.cb must also be specified"}9991000r = {}1001# get data about user queries on this table1002if opts.project_id?1003r.client_query = SCHEMA[opts.table]?.project_query1004else1005r.client_query = SCHEMA[opts.table]?.user_query10061007if not r.client_query?.get?1008return {err: "FATAL: get queries not allowed for table '#{opts.table}'"}10091010if not opts.account_id? and not opts.project_id? and not SCHEMA[opts.table].anonymous1011return {err: "FATAL: anonymous get queries not allowed for table '#{opts.table}'"}10121013r.table = SCHEMA[opts.table].virtual ? opts.table10141015r.primary_keys = @_primary_keys(opts.table)10161017# Are only admins allowed any get access to this table?1018r.require_admin = !!r.client_query.get.admin10191020# Verify that all requested fields may be read by users1021for field in misc.keys(opts.query)1022if r.client_query.get.fields?[field] == undefined1023return {err: "FATAL: user get query not allowed for #{opts.table}.#{field}"}10241025# Functional substitutions defined by schema1026@_user_get_query_functional_subs(opts.query, r.client_query.get?.fields)10271028if r.client_query.get?.instead_of_query?1029return r10301031# Sanity check: make sure there is something in the query1032# that gets only things in this table that this user1033# is allowed to see, or at least a check_hook. This is not required1034# for admins.1035if not r.client_query.get.pg_where? and not r.client_query.get.check_hook? and not r.require_admin1036return {err: "FATAL: user get query not allowed for #{opts.table} (no getAll filter - pg_where or check_hook)"}10371038# Apply default options to the get query (don't impact changefeed)1039# The user can override these, e.g., if they were to want to explicitly increase a limit1040# to get more file use history.1041user_options = {}1042for x in opts.options1043for y, z of x1044user_options[y] = true10451046get_options = undefined1047if @is_heavily_loaded() and r.client_query.get.options_load?1048get_options = r.client_query.get.options_load1049else if r.client_query.get.options?1050get_options = r.client_query.get.options1051if get_options?1052# complicated since options is a list of {opt:val} !1053for x in get_options1054for y, z of x1055if not user_options[y]1056opts.options.push(x)1057break10581059r.json_fields = @_json_fields(opts.table, opts.query)1060return r10611062# _json_fields: map from field names to array of fields that should be parsed as timestamps1063# These keys of his map are also used by _user_query_set_upsert_and_jsonb_merge to determine1064# JSON deep merging for set queries.1065_json_fields: (table, query) =>1066json_fields = {}1067for field, info of SCHEMA[table].fields1068if (query[field]? or query[field] == null) and (info.type == 'map' or info.pg_type == 'JSONB')1069json_fields[field] = info.date ? []1070return json_fields10711072_user_get_query_where: (client_query, account_id, project_id, user_query, table, cb) =>1073dbg = @_dbg("_user_get_query_where")1074dbg()10751076pg_where = client_query.get.pg_where10771078if @is_heavily_loaded() and client_query.get.pg_where_load?1079# use a different query if load is heavy1080pg_where = client_query.get.pg_where_load10811082if not pg_where?1083pg_where = []1084if pg_where == 'projects'1085pg_where = ['projects']10861087if typeof(pg_where) == 'function'1088pg_where = pg_where(user_query, @)1089if not misc.is_array(pg_where)1090cb("FATAL: pg_where must be an array (of strings or objects)")1091return10921093# Do NOT mutate the schema itself!1094pg_where = misc.deep_copy(pg_where)10951096# expand 'projects' in query, depending on whether project_id is specified or not.1097# This is just a convenience to make the db schema simpler.1098for i in [0...pg_where.length]1099if pg_where[i] == 'projects'1100if user_query.project_id1101pg_where[i] = {"project_id = $::UUID" : 'project_id'}1102else1103pg_where[i] = {"project_id = ANY(select project_id from projects where users ? $::TEXT)" : 'account_id'}11041105# Now we fill in all the parametrized substitutions in the pg_where list.1106subs = {}1107for x in pg_where1108if misc.is_object(x)1109for _, value of x1110subs[value] = value11111112sub_value = (value, cb) =>1113switch value1114when 'account_id'1115if not account_id?1116cb('FATAL: account_id must be given')1117return1118subs[value] = account_id1119cb()1120when 'project_id'1121if project_id?1122subs[value] = project_id1123cb()1124else if not user_query.project_id1125cb("FATAL: must specify project_id")1126else if SCHEMA[table].anonymous1127subs[value] = user_query.project_id1128cb()1129else1130@user_is_in_project_group1131account_id : account_id1132project_id : user_query.project_id1133groups : ['owner', 'collaborator']1134cb : (err, in_group) =>1135if err1136cb(err)1137else if in_group1138subs[value] = user_query.project_id1139cb()1140else1141cb("FATAL: you do not have read access to this project -- account_id=#{account_id}, project_id_=#{project_id}")1142when 'project_id-public'1143if not user_query.project_id?1144cb("FATAL: must specify project_id")1145else1146if SCHEMA[table].anonymous1147@has_public_path1148project_id : user_query.project_id1149cb : (err, has_public_path) =>1150if err1151cb(err)1152else if not has_public_path1153cb("project does not have any public paths")1154else1155subs[value] = user_query.project_id1156cb()1157else1158cb("FATAL: table must allow anonymous queries")1159else1160cb()11611162async.map misc.keys(subs), sub_value, (err) =>1163if err1164cb(err)1165return1166for x in pg_where1167if misc.is_object(x)1168for key, value of x1169x[key] = subs[value]11701171# impose further restrictions (more where conditions)1172pg_where.push(userGetQueryFilter(user_query, client_query))11731174cb(undefined, pg_where)11751176_user_get_query_options: (options, multi, schema_options) =>1177r = {}11781179if schema_options?1180options = options.concat(schema_options)11811182# Parse option part of the query1183{limit, order_by, slice, only_changes, err} = @_query_parse_options(options)11841185if err1186return {err: err}1187if only_changes1188r.only_changes = true1189if limit?1190r.limit = limit1191else if not multi1192r.limit = 11193if order_by?1194r.order_by = order_by1195if slice?1196return {err: "slice not implemented"}1197return r11981199_user_get_query_do_query: (query_opts, client_query, user_query, multi, json_fields, cb) =>1200query_opts.cb = all_results (err, x) =>1201if err1202cb(err)1203else1204if misc.len(json_fields) > 01205# Convert timestamps to Date objects, if **explicitly** specified in the schema1206for obj in x1207@_user_get_query_json_timestamps(obj, json_fields)12081209if not multi1210x = x[0]1211# Fill in default values and remove null's1212@_user_get_query_set_defaults(client_query, x, misc.keys(user_query))1213# Get rid of undefined fields -- that's the default and wastes memory and bandwidth1214if x?1215for obj in x1216misc.map_mutate_out_undefined_and_null(obj)1217cb(undefined, x)1218@_query(query_opts)12191220_user_get_query_query: (table, user_query, remove_from_query) =>1221return "SELECT #{(quote_field(field) for field in @_user_get_query_columns(user_query, remove_from_query)).join(',')} FROM #{table}"12221223_user_get_query_satisfied_by_obj: (user_query, obj, possible_time_fields) =>1224#dbg = @_dbg("_user_get_query_satisfied_by_obj)1225#dbg(user_query, obj)1226for field, value of obj1227date_keys = possible_time_fields[field]1228if date_keys1229value = misc.fix_json_dates(value, date_keys)1230if (q = user_query[field])?1231if (op = queryIsCmp(q))1232#dbg(value:value, op: op, q:q)1233x = q[op]1234switch op1235when '=='1236if value != x1237return false1238when '!='1239if value == x1240return false1241when '>='1242if value < x1243return false1244when '<='1245if value > x1246return false1247when '>'1248if value <= x1249return false1250when '<'1251if value >= x1252return false1253else if value != q1254return false1255return true12561257_user_get_query_handle_field_deletes: (client_query, new_val) =>1258if client_query.get.allow_field_deletes1259# leave in the nulls that might be in new_val1260return1261# remove all nulls from new_val. Right now we1262# just can't support this due to default values.1263# TODO: completely get rid of default values (?) or1264# maybe figure out how to implement this. The symptom1265# of not doing this is a normal user will do things like1266# delete the users field of their projects. Not good.1267for key of new_val1268if not new_val[key]?1269delete new_val[key]12701271_user_get_query_changefeed: (changes, table, primary_keys, user_query,1272where, json_fields, account_id, client_query, orig_table, cb) =>1273dbg = @_dbg("_user_get_query_changefeed(table='#{table}')")1274dbg()1275# WARNING: always call changes.cb! Do not do something like f = changes.cb, then call f!!!!1276# This is because the value of changes.cb may be changed by the caller.1277if not misc.is_object(changes)1278cb("FATAL: changes must be an object with keys id and cb")1279return1280if not misc.is_valid_uuid_string(changes.id)1281cb("FATAL: changes.id must be a uuid")1282return1283if typeof(changes.cb) != 'function'1284cb("FATAL: changes.cb must be a function")1285return1286for primary_key in primary_keys1287if not user_query[primary_key]? and user_query[primary_key] != null1288cb("FATAL: changefeed MUST include primary key (='#{primary_key}') in query")1289return1290watch = []1291select = {}1292init_tracker = tracker = free_tracker = undefined1293possible_time_fields = misc.deep_copy(json_fields)1294feed = undefined12951296changefeed_keys = SCHEMA[orig_table]?.changefeed_keys ? SCHEMA[table]?.changefeed_keys ? []1297for field, val of user_query1298type = pg_type(SCHEMA[table]?.fields?[field])1299if type == 'TIMESTAMP'1300possible_time_fields[field] = 'all'1301if val == null and field not in primary_keys and field not in changefeed_keys1302watch.push(field)1303else1304select[field] = type13051306if misc.len(possible_time_fields) > 01307# Convert (likely) timestamps to Date objects; fill in defaults for inserts1308process = (x) =>1309if not x?1310return1311if x.new_val?1312@_user_get_query_json_timestamps(x.new_val, possible_time_fields)1313if x.action == 'insert' # do not do this for delete or update actions!1314@_user_get_query_set_defaults(client_query, x.new_val, misc.keys(user_query))1315else if x.action == 'update'1316@_user_get_query_handle_field_deletes(client_query, x.new_val)1317if x.old_val?1318@_user_get_query_json_timestamps(x.old_val, possible_time_fields)1319else1320process = (x) =>1321if not x?1322return1323if x.new_val?1324if x.action == 'insert' # do not do this for delete or update actions!1325@_user_get_query_set_defaults(client_query, x.new_val, misc.keys(user_query))1326else if x.action == 'update'1327@_user_get_query_handle_field_deletes(client_query, x.new_val)13281329async.series([1330(cb) =>1331# check for alternative where test for changefeed.1332pg_changefeed = client_query?.get?.pg_changefeed1333if not pg_changefeed?1334cb(); return13351336if pg_changefeed == 'projects'1337tracker_add = (project_id) => feed.insert({project_id:project_id})1338tracker_remove = (project_id) => feed.delete({project_id:project_id})13391340# Any tracker error means this changefeed is now broken and1341# has to be recreated.1342tracker_error = () => changes.cb("tracker error - ${err}")13431344pg_changefeed = (db, account_id) =>1345where : (obj) =>1346# Check that this is a project we have read access to1347if not db._project_and_user_tracker?.get_projects(account_id)[obj.project_id]1348return false1349# Now check our actual query conditions on the object.1350# This would normally be done by the changefeed, but since1351# we are passing in a custom where, we have to do it.1352if not @_user_get_query_satisfied_by_obj(user_query, obj, possible_time_fields)1353return false1354return true13551356select : {'project_id':'UUID'}13571358init_tracker : (tracker) =>1359tracker.on "add_user_to_project-#{account_id}", tracker_add1360tracker.on "remove_user_from_project-#{account_id}", tracker_remove1361tracker.once 'error', tracker_error136213631364free_tracker : (tracker) =>1365dbg("freeing project tracker events")1366tracker.removeListener("add_user_to_project-#{account_id}", tracker_add)1367tracker.removeListener("remove_user_from_project-#{account_id}", tracker_remove)1368tracker.removeListener("error", tracker_error)136913701371else if pg_changefeed == 'news'1372pg_changefeed = ->1373where : (obj) ->1374if obj.date?1375date_obj = new Date(obj.date)1376# we send future news items to the frontend, but filter it based on the server time1377return date_obj >= misc.months_ago(3)1378else1379return true1380select : {id: 'SERIAL UNIQUE', date: 'TIMESTAMP'}13811382else if pg_changefeed == 'one-hour'1383pg_changefeed = ->1384where : (obj) ->1385if obj.time?1386return new Date(obj.time) >= misc.hours_ago(1)1387else1388return true1389select : {id:'UUID', time:'TIMESTAMP'}13901391else if pg_changefeed == 'five-minutes'1392pg_changefeed = ->1393where : (obj) ->1394if obj.time?1395return new Date(obj.time) >= misc.minutes_ago(5)1396else1397return true1398select : {id:'UUID', time:'TIMESTAMP'}13991400else if pg_changefeed == 'collaborators'1401if not account_id?1402cb("FATAL: account_id must be given")1403return1404tracker_add = (collab_id) => feed.insert({account_id:collab_id})1405tracker_remove = (collab_id) => feed.delete({account_id:collab_id})1406tracker_error = () => changes.cb("tracker error - ${err}")1407pg_changefeed = (db, account_id) ->1408shared_tracker = undefined1409where : (obj) -> # test of "is a collab with me"1410return shared_tracker.get_collabs(account_id)?[obj.account_id]1411init_tracker : (tracker) =>1412shared_tracker = tracker1413tracker.on "add_collaborator-#{account_id}", tracker_add1414tracker.on "remove_collaborator-#{account_id}", tracker_remove1415tracker.once 'error', tracker_error1416free_tracker : (tracker) =>1417dbg("freeing collab tracker events")1418tracker.removeListener("add_collaborator-#{account_id}", tracker_add)1419tracker.removeListener("remove_collaborator-#{account_id}", tracker_remove)1420tracker.removeListener("error", tracker_error)142114221423x = pg_changefeed(@, account_id)1424if x.init_tracker?1425init_tracker = x.init_tracker1426if x.free_tracker?1427free_tracker = x.free_tracker1428if x.select?1429for k, v of x.select1430select[k] = v14311432if x.where? or x.init_tracker?1433where = x.where1434if not account_id?1435cb()1436return1437# initialize user tracker is needed for where tests...1438@project_and_user_tracker cb : (err, _tracker) =>1439if err1440cb(err)1441else1442tracker = _tracker1443try1444await tracker.register(account_id)1445cb()1446catch err1447cb(err)1448else1449cb()1450(cb) =>1451@changefeed1452table : table1453select : select1454where : where1455watch : watch1456cb : (err, _feed) =>1457# there *is* a glboal variable feed that we set here:1458feed = _feed1459if err1460cb(err)1461return1462feed.on 'change', (x) ->1463process(x)1464changes.cb(undefined, x)1465feed.on 'close', ->1466changes.cb(undefined, {action:'close'})1467dbg("feed close")1468if tracker? and free_tracker?1469dbg("free_tracker")1470free_tracker(tracker)1471else1472dbg("do NOT free_tracker")1473feed.on 'error', (err) ->1474changes.cb("feed error - #{err}")1475@_changefeeds ?= {}1476@_changefeeds[changes.id] = feed1477init_tracker?(tracker)1478cb()1479], cb)14801481user_get_query: (opts) =>1482opts = defaults opts,1483account_id : undefined1484project_id : undefined1485table : required1486query : required1487multi : required1488options : required # used for initial query; **IGNORED** by changefeed,1489# which ensures that *something* is sent every n minutes, in case no1490# changes are coming out of the changefeed. This is an additional1491# measure in case the client somehow doesn't get a "this changefeed died" message.1492# Use [{delete:true}] to instead delete the selected records (must1493# have delete:true in schema).1494changes : undefined # {id:?, cb:?}1495cb : required # cb(err, result)1496###1497The general idea is that user get queries are of the form14981499SELECT [columns] FROM table WHERE [get_all] AND [further restrictions] LIMIT/slice15001501Using the whitelist rules specified in SCHEMA, we1502determine each of the above, then run the query.15031504If no error in query, and changes is a given uuid, set up a change1505feed that calls opts.cb on changes as well.1506###1507id = misc.uuid().slice(0,6)1508#dbg = @_dbg("user_get_query(id=#{id})")1509dbg = -> # Logging below is just too verbose, and turns out to not be useful...1510dbg("account_id='#{opts.account_id}', project_id='#{opts.project_id}', query=#{misc.to_json(opts.query)}, multi=#{opts.multi}, options=#{misc.to_json(opts.options)}, changes=#{misc.to_json(opts.changes)}")1511{err, table, client_query, require_admin, primary_keys, json_fields} = @_parse_get_query_opts(opts)15121513if err1514dbg("error parsing query opts -- #{err}")1515opts.cb(err)1516return15171518_query_opts = {} # this will be the input to the @_query command.1519locals =1520result : undefined1521changes_cb : undefined15221523async.series([1524(cb) =>1525if client_query.get.check_hook?1526dbg("do check hook")1527client_query.get.check_hook(@, opts.query, opts.account_id, opts.project_id, cb)1528else1529cb()1530(cb) =>1531if require_admin1532dbg('require admin')1533@_require_is_admin(opts.account_id, cb)1534else1535cb()1536(cb) =>1537# NOTE: _user_get_query_where may mutate opts.query (for 'null' params)1538# so it is important that this is called before @_user_get_query_query below.1539# See the TODO in userGetQueryFilter.1540dbg("get_query_where")1541@_user_get_query_where client_query, opts.account_id, opts.project_id, opts.query, opts.table, (err, where) =>1542_query_opts.where = where1543cb(err)1544(cb) =>1545if client_query.get.instead_of_query?1546cb();1547return1548_query_opts.query = @_user_get_query_query(table, opts.query, client_query.get.remove_from_query)1549x = @_user_get_query_options(opts.options, opts.multi, client_query.options)1550if x.err1551dbg("error in get_query_options, #{x.err}")1552cb(x.err)1553return1554misc.merge(_query_opts, x)15551556nestloop = SCHEMA[opts.table]?.pg_nestloop # true, false or undefined1557if typeof nestloop == 'boolean'1558val = if nestloop then 'on' else 'off'1559_query_opts.pg_params = {enable_nestloop : val}15601561indexscan = SCHEMA[opts.table]?.pg_indexscan # true, false or undefined1562if typeof indexscan == 'boolean'1563val = if indexscan then 'on' else 'off'1564_query_opts.pg_params = {enable_indexscan : val}15651566if opts.changes?1567locals.changes_cb = opts.changes.cb1568locals.changes_queue = []1569# see note about why we do the following at the bottom of this file1570opts.changes.cb = (err, obj) ->1571locals.changes_queue.push({err:err, obj:obj})1572dbg("getting changefeed")1573@_user_get_query_changefeed(opts.changes, table, primary_keys,1574opts.query, _query_opts.where, json_fields,1575opts.account_id, client_query, opts.table,1576cb)1577else1578cb()15791580(cb) =>1581if client_query.get.instead_of_query?1582if opts.changes?1583cb("changefeeds are not supported for querying this table")1584return1585# Custom version: instead of doing a full query, we instead1586# call a function and that's it.1587dbg("do instead_of_query instead")1588opts1 = misc.copy_without(opts, ['cb', 'changes', 'table'])1589client_query.get.instead_of_query @, opts1, (err, result) =>1590locals.result = result1591cb(err)1592return15931594if _query_opts.only_changes1595dbg("skipping query")1596locals.result = undefined1597cb()1598else1599dbg("finally doing query")1600@_user_get_query_do_query _query_opts, client_query, opts.query, opts.multi, json_fields, (err, result) =>1601if err1602cb(err)1603return1604locals.result = result1605cb()1606], (err) =>1607if err1608dbg("series failed -- err=#{err}")1609opts.cb(err)1610return1611dbg("series succeeded")1612opts.cb(undefined, locals.result)1613if opts.changes?1614dbg("sending change queue")1615opts.changes.cb = locals.changes_cb1616##dbg("sending queued #{JSON.stringify(locals.changes_queue)}")1617for {err, obj} in locals.changes_queue1618##dbg("sending queued changes #{JSON.stringify([err, obj])}")1619opts.changes.cb(err, obj)1620)16211622###1623Synchronized strings1624###1625_user_set_query_syncstring_change_after: (old_val, new_val, account_id, cb) =>1626dbg = @_dbg("_user_set_query_syncstring_change_after")1627cb() # return immediately -- stuff below can happen as side effect in the background.1628# Now do the following reactions to this syncstring change in the background:1629# 1. Awaken the relevant project.1630project_id = old_val?.project_id ? new_val?.project_id1631if project_id? and (new_val?.save?.state == 'requested' or (new_val?.last_active? and new_val?.last_active != old_val?.last_active))1632dbg("awakening project #{project_id}")1633awaken_project(@, project_id)163416351636# Verify that writing a patch is allowed.1637_user_set_query_patches_check: (obj, account_id, project_id, cb) =>1638# Reject any patch that is too new1639if obj.time - new Date() > MAX_PATCH_FUTURE_MS1640cb("clock") # this exact error is assumed in synctable!1641return1642# Write access1643@_syncstring_access_check(obj.string_id, account_id, project_id, cb)16441645# Verify that writing a patch is allowed.1646_user_get_query_patches_check: (obj, account_id, project_id, cb) =>1647# Write access (no notion of read only yet -- will be easy to add later)1648@_syncstring_access_check(obj.string_id, account_id, project_id, cb)16491650# Verify that writing a patch is allowed.1651_user_set_query_cursors_check: (obj, account_id, project_id, cb) =>1652@_syncstring_access_check(obj.string_id, account_id, project_id, cb)16531654# Verify that writing a patch is allowed.1655_user_get_query_cursors_check: (obj, account_id, project_id, cb) =>1656@_syncstring_access_check(obj.string_id, account_id, project_id, cb)16571658_syncstring_access_check: (string_id, account_id, project_id, cb) =>1659# Check that string_id is the id of a syncstring the given account_id or1660# project_id is allowed to write to. NOTE: We do not concern ourselves (for now at least)1661# with proof of identity (i.e., one user with full read/write access to a project1662# claiming they are another users of that SAME project), since our security model1663# is that any user of a project can edit anything there. In particular, the1664# synctable lets any user with write access to the project edit the users field.1665if string_id?.length != 401666cb("FATAL: string_id (='#{string_id}') must be a string of length 40")1667return1668@_query1669query : "SELECT project_id FROM syncstrings"1670where : "string_id = $::CHAR(40)" : string_id1671cache : false # *MUST* leave as false (not true), since unfortunately, if this returns no, due to FATAL below this would break opening the file until cache clears.1672cb : one_result 'project_id', (err, x) =>1673if err1674cb(err)1675else if not x1676# There is no such syncstring with this id -- fail1677cb("FATAL: no such syncstring")1678else if account_id?1679# Attempt to read or write by a user browser client1680@_require_project_ids_in_groups(account_id, [x], ['owner', 'collaborator'], cb)1681else if project_id?1682# Attempt to read or write by a *project*1683if project_id == x1684cb()1685else1686cb("FATAL: project not allowed to write to syncstring in different project")168716881689# Check permissions for querying for syncstrings in a project1690_syncstrings_check: (obj, account_id, project_id, cb) =>1691#dbg = @dbg("_syncstrings_check")1692#dbg(misc.to_json([obj, account_id, project_id]))1693if not misc.is_valid_uuid_string(obj?.project_id)1694cb("FATAL: project_id (='#{obj?.project_id}') must be a valid uuid")1695return1696if project_id?1697if project_id == obj.project_id1698# The project can access its own syncstrings1699cb()1700else1701cb("FATAL: projects can only access their own syncstrings") # for now at least!1702return1703if account_id?1704# Access request by a client user1705@_require_project_ids_in_groups(account_id, [obj.project_id], ['owner', 'collaborator'], cb)1706else1707cb("FATAL: only users and projects can access syncstrings")17081709# Other functions that are needed to implement various use queries,1710# e.g., for virtual queries like file_use_times.1711# ASYNC FUNCTION with no callback.1712updateRetentionData: (opts) =>1713return await updateRetentionData(opts)17141715_last_awaken_time = {}1716awaken_project = (db, project_id, cb) ->1717# throttle so that this gets called *for a given project* at most once every 30s.1718now = new Date()1719if _last_awaken_time[project_id]? and now - _last_awaken_time[project_id] < 300001720return1721_last_awaken_time[project_id] = now1722dbg = db._dbg("_awaken_project(project_id=#{project_id})")1723if not db.projectControl?1724dbg("skipping since no projectControl defined")1725return1726dbg("doing it...")1727async.series([1728(cb) ->1729try1730project = db.projectControl(project_id)1731await project.start()1732cb()1733catch err1734cb("error starting project = #{err}")1735(cb) ->1736if not db.ensure_connection_to_project?1737cb()1738return1739dbg("also make sure there is a connection from hub to project")1740# This is so the project can find out that the user wants to save a file (etc.)1741db.ensure_connection_to_project(project_id, cb)1742], (err) ->1743if err1744dbg("awaken project error -- #{err}")1745else1746dbg("success awakening project")1747cb?(err)1748)1749###1750Note about opts.changes.cb:17511752Regarding sync, what was happening I think is:1753- (a) https://github.com/sagemathinc/cocalc/blob/master/src/packages/hub/postgres-user-queries.coffee#L1384 starts sending changes1754- (b) https://github.com/sagemathinc/cocalc/blob/master/src/packages/hub/postgres-user-queries.coffee#L1393 sends the full table.17551756(a) could result in changes actually getting to the client before the table itself has been initialized. The client code assumes that it only gets changes *after* the table is initialized. The browser client seems to be smart enough that it detects this situation and resets itself, so the browser never gets messed up as a result.1757However, the project definitely does NOT do so well, and it can get messed up. Then it has a broken version of the table, missing some last minute change. It is broken until the project forgets about that table entirely, which is can be a pretty long time (or project restart).17581759My fix is to queue up those changes on the server, then only start sending them to the client **after** the (b) query is done. I tested this by using setTimeout to manually delay (b) for a few seconds, and fully seeing the "file won't save problem". The other approach would make it so clients are more robust against getting changes first. However, it would take a long time for all clients to update (restart all projects), and it's an annoying assumption to make in general -- we may have entirely new clients later and they could make the same bad assumptions about order...1760###176117621763