diff --git a/src/scripts/zvol_storage.py b/src/scripts/zvol_storage.py index 1574ee1c56..c82510e715 100755 --- a/src/scripts/zvol_storage.py +++ b/src/scripts/zvol_storage.py @@ -73,12 +73,12 @@ SSH_ACCESS_PUBLIC_KEY = "/home/salvus/salvus/salvus/scripts/skel/.ssh/authorized_keys2" -import argparse, hashlib, math, os, random, shutil, string, sys, time, uuid, json, signal +import argparse, hashlib, math, os, shutil, sys, time, uuid, json, signal from subprocess import Popen, PIPE def print_json(s): - print json.dumps(s, separators=(',', ':')) + print(json.dumps(s, separators=(',', ':'))) def uid(project_id): @@ -227,8 +227,11 @@ def __repr__(self): return "Stream(%s): %s to %s stored in %s" % ( self.project.project_id, self.start, self.end, self.path) - def __cmp__(self, other): - return cmp((self.end, self.start), (other.end, other.start)) + def __eq__(self, other): + return (self.end, self.start) == (other.end, other.start) + + def __ne__(self, other): + return not(self == other) def size_mb(self): return int(os.path.getsize(self.path) / 1e6) @@ -343,7 +346,6 @@ def create_user(self): ignore_errors=True) def delete_user(self): - u = self.uid cmd('sudo /usr/sbin/userdel %s; sudo /usr/sbin/groupdel %s' % (self.username, self.username), ignore_errors=True) diff --git a/src/smc-hub/client.coffee b/src/smc-hub/client.coffee index 82d1cf227a..a2f3f9fc55 100644 --- a/src/smc-hub/client.coffee +++ b/src/smc-hub/client.coffee @@ -33,6 +33,8 @@ db_schema = require('smc-util/db-schema') underscore = require('underscore') +{callback} = require('awaiting') + DEBUG2 = !!process.env.SMC_DEBUG2 REQUIRE_ACCOUNT_TO_EXECUTE_CODE = false @@ -2603,6 +2605,25 @@ class exports.Client extends EventEmitter else cb() + _check_syncdoc_access: (string_id, cb) => + if not @account_id? + cb('you must be signed in to access syncdoc') + return + if not typeof string_id == 'string' and string_id.length == 40 + cb('string_id must be specified and valid') + return + @database._query + query : "SELECT project_id FROM syncstrings" + where : {"string_id = $::CHAR(40)" : string_id} + cb : (err, results) => + if err + cb(err) + else if results.rows.length != 1 + cb("no such syncdoc") + else + project_id = results.rows[0].project_id + @_check_project_access(project_id, cb) + mesg_disconnect_from_project: (mesg) => dbg = @dbg('mesg_disconnect_from_project') @_check_project_access mesg.project_id, (err) => @@ -2637,4 +2658,19 @@ class exports.Client extends EventEmitter @error_to_client(id:mesg.id, error:"unable to touch project #{mesg.project_id} -- #{err}") else @push_to_client(message.success(id:mesg.id)) - ) \ No newline at end of file + ) + + mesg_get_syncdoc_history: (mesg) => + dbg = @dbg('mesg_syncdoc_history') + try + dbg("checking conditions") + # this raises an error if user does not have access + await callback(@_check_syncdoc_access, mesg.string_id) + # get the history + history = await @database.syncdoc_history_async(mesg.string_id, mesg.patches) + dbg("success!") + @push_to_client(message.syncdoc_history(id:mesg.id, history:history)) + catch err + dbg("failed -- #{err}") + @error_to_client(id:mesg.id, error:"unable to get syncdoc history for string_id #{mesg.string_id} -- #{err}") + diff --git a/src/smc-hub/local_hub_connection.coffee b/src/smc-hub/local_hub_connection.coffee index 41bd13b58e..51ba9bf0dc 100644 --- a/src/smc-hub/local_hub_connection.coffee +++ b/src/smc-hub/local_hub_connection.coffee @@ -341,6 +341,31 @@ class LocalHub # use the function "new_local_hub" above; do not construct this d cb() async.map(misc.keys(v), f, (err) => cb?(err)) + # async -- throws error if project doesn't have access to string with this id. + check_syncdoc_access: (string_id) => + if not typeof string_id == 'string' and string_id.length == 40 + throw Error('string_id must be specified and valid') + return + opts = + query : "SELECT project_id FROM syncstrings" + where : {"string_id = $::CHAR(40)" : string_id} + results = await callback2(@database._query, opts) + if results.rows.length != 1 + throw Error("no such syncdoc") + if results.rows[0].project_id != @project_id + throw Error("project does NOT have access to this syncdoc") + return # everything is fine. + + mesg_get_syncdoc_history: (mesg, write_mesg) => + try + # this raises an error if user does not have access + await @check_syncdoc_access(mesg.string_id) + # get the history + history = await @database.syncdoc_history_async(mesg.string_id, mesg.patches) + write_mesg(message.syncdoc_history(id:mesg.id, history:history)) + catch err + write_mesg(message.error(id:mesg.id, error:"unable to get syncdoc history for string_id #{mesg.string_id} -- #{err}")) + # # end project query support code # @@ -421,6 +446,8 @@ class LocalHub # use the function "new_local_hub" above; do not construct this d @mesg_query(mesg, write_mesg) when 'query_cancel' @mesg_query_cancel(mesg, write_mesg) + when 'get_syncdoc_history' + @mesg_get_syncdoc_history(mesg, write_mesg) when 'file_written_to_project' # ignore -- don't care; this is going away return diff --git a/src/smc-hub/postgres-server-queries.coffee b/src/smc-hub/postgres-server-queries.coffee index 454ad890ae..96247acf1f 100644 --- a/src/smc-hub/postgres-server-queries.coffee +++ b/src/smc-hub/postgres-server-queries.coffee @@ -26,6 +26,8 @@ PROJECT_GROUPS = misc.PROJECT_GROUPS {PROJECT_COLUMNS, one_result, all_results, count_result, expire_time} = require('./postgres-base') +{syncdoc_history} = require('./postgres/syncdoc-history') + exports.extend_PostgreSQL = (ext) -> class PostgreSQL extends ext # write an event to the central_log table log: (opts) => @@ -2737,3 +2739,16 @@ exports.extend_PostgreSQL = (ext) -> class PostgreSQL extends ext where : locals.where cb : cb ], opts.cb) + + syncdoc_history: (opts) => + opts = defaults opts, + string_id : required + patches : false # if true, include actual patches + cb : required + try + opts.cb(undefined, await syncdoc_history(@, opts.string_id, opts.patches)) + catch err + opts.cb(err) + + syncdoc_history_async : (string_id, patches) => + return await syncdoc_history(@, string_id, patches) diff --git a/src/smc-hub/postgres/syncdoc-history.ts b/src/smc-hub/postgres/syncdoc-history.ts new file mode 100644 index 0000000000..6d0b471617 --- /dev/null +++ b/src/smc-hub/postgres/syncdoc-history.ts @@ -0,0 +1,94 @@ +import { callback2 } from "smc-util/async-utils"; + +import { trunc } from "smc-util/misc2"; + +import { PostgreSQL } from "./types"; + +export interface Patch { + time_utc: Date; + patch_length?: number; + patch?: string; + user?: string; + account_id?: string; + format?: number; + snapshot?: string; +} + +type User = { account_id: string; user: string }; + +async function get_users(db: PostgreSQL, where): Promise { + const query = "SELECT project_id, users FROM syncstrings"; + // get the user_id --> account_id map + const results = await callback2(db._query, { query, where }); + if (results.rows.length != 1) { + throw Error("no such syncstring"); + } + const account_ids: string[] = results.rows[0].users + ? results.rows[0].users + : []; // syncdoc exists, but not used yet. + const project_id: string = results.rows[0].project_id; + const project_title: string = trunc( + (await callback2(db.get_project, { + columns: ["title"], + project_id + })).title, + 80 + ); + + // get the names of the users + const names = await callback2(db.account_ids_to_usernames, { account_ids }); + const users: User[] = []; + for (let account_id of account_ids) { + if (account_id == project_id) { + users.push({ account_id, user: `Project: ${project_title}` }); + continue; + } + const name = names[account_id]; + if (name == null) continue; + const user = trunc(`${name.first_name} ${name.last_name}`, 80); + users.push({ account_id, user }); + } + return users; +} + +export async function syncdoc_history( + db: PostgreSQL, + string_id: string, + include_patches: boolean = false +): Promise { + const where = { "string_id = $::CHAR(40)": string_id }; + const users: User[] = await get_users(db, where); + + const order_by = "time"; + let query: string; + if (include_patches) { + query = "SELECT time, user_id, format, patch, snapshot FROM patches"; + } else { + query = + "SELECT time, user_id, format, length(patch) as patch_length FROM patches"; + } + const results = await callback2(db._query, { query, where, order_by }); + const patches: Patch[] = []; + function format_patch(row): Patch { + const patch: Patch = { time_utc: row.time, format: row.format }; + const u = users[row.user_id]; + if (u != null) { + for (let k in u) { + patch[k] = u[k]; + } + } + if (include_patches) { + patch.patch = row.patch; + if (row.snapshot != null) { + patch.snapshot = row.snapshot; + } + } else { + patch.patch_length = row.patch_length; + } + return patch; + } + for (let row of results.rows) { + patches.push(format_patch(row)); + } + return patches; +} diff --git a/src/smc-hub/postgres/types.ts b/src/smc-hub/postgres/types.ts index 579a087201..9aac4a16a7 100644 --- a/src/smc-hub/postgres/types.ts +++ b/src/smc-hub/postgres/types.ts @@ -43,4 +43,6 @@ export interface PostgreSQL extends EventEmitter { cb: Function ): void; changefeed(opts: ChangefeedOptions): Changes; + account_ids_to_usernames(opts: { account_ids: string[]; cb: Function }); + get_project(opts: { project_id: string; columns?: string[]; cb: Function }); } diff --git a/src/smc-project/client.coffee b/src/smc-project/client.coffee index 3dd0b84046..15b34e4729 100644 --- a/src/smc-project/client.coffee +++ b/src/smc-project/client.coffee @@ -24,7 +24,7 @@ fs = require('fs') {EventEmitter} = require('events') -{once} = require("smc-util/async-utils"); +{callback2, once} = require("smc-util/async-utils"); async = require('async') winston = require('winston') @@ -583,4 +583,13 @@ class exports.Client extends EventEmitter # no-op touch_project: (opts) => - opts.cb?() \ No newline at end of file + opts.cb?() + + # async + get_syncdoc_history: (string_id, patches=false) => + dbg = @dbg("get_syncdoc_history") + dbg(string_id, patches) + mesg = message.get_syncdoc_history + string_id : string_id + patches : patches + return await callback2(@call, {message:mesg}) diff --git a/src/smc-project/http-api/server.ts b/src/smc-project/http-api/server.ts new file mode 100644 index 0000000000..37235f5918 --- /dev/null +++ b/src/smc-project/http-api/server.ts @@ -0,0 +1,170 @@ +/* +Express HTTP API server + +This is meant to be used from within the project via localhost, both +to get info from the project, and to cause the project to do things. + +Requests must be authenticated using the secret token. +*/ + +const MAX_REQUESTS_PER_MINUTE = 50; + +import * as express from "express"; +import { writeFile } from "fs"; +import { callback } from "awaiting"; +import { meta_file } from "../smc-util/misc"; +import { endswith, split } from "../smc-util/misc2"; +import { json, urlencoded } from "body-parser"; + +const { free_port } = require("../../smc-util-node/misc_node"); +const { client_db } = require("../smc-util/db-schema"); +const RateLimit = require("express-rate-limit"); + +export interface Client { + project_id: string; + secret_token: string; + get_syncdoc_history: (string_id: string, patches: boolean) => Promise; + dbg: (name: string) => Function; +} + +interface ServerOpts { + client: Client; + port?: number; + port_path?: string; +} + +export async function start_server(opts: ServerOpts): Promise { + const dbg: Function = opts.client.dbg("api_server"); + const server: express.Application = express(); + + dbg("configuring server..."); + configure(opts, server, dbg); + + if (opts.port == null) { + dbg("getting free port..."); + opts.port = await callback(free_port); + dbg(`got port=${opts.port}`); + } + + if (opts.port_path) { + dbg(`writing port to file "${opts.port_path}"`); + await callback(writeFile, opts.port_path, opts.port); + } + + // TODO/RANT: I cannot figure out how to catch an error + // due to port being taken. I couldn't find any useful + // docs on this. The callback function doesn't get called + // with an error, and there is no "error" event, since + // the only event is "mount" -- + // https://expressjs.com/en/4x/api.html#app.onmount + // I wasted way too much time on this. Googling is also + // miserable, since the express api has changed dramatically + // so many times. At least it doesn't hang, and instead + // exits the process. + function start(cb: Function): void { + server.listen(opts.port, () => { + cb(); + }); + } + await callback(start); + dbg(`express server successfully listening at http://localhost:${opts.port}`); +} + +function configure( + opts: ServerOpts, + server: express.Application, + dbg: Function +): void { + server.use(json({ limit: "3mb" })); + server.use(urlencoded({ extended: true, limit: "3mb" })); + + rate_limit(server); + + server.get("/", handle_get); + + server.post("/api/v1/*", async (req, res) => { + dbg(`POST to ${req.path}`); + try { + handle_auth(req, opts.client.secret_token); + await handle_post(req, res, opts.client); + } catch (err) { + dbg(`failed handling POST ${err}`); + res.status(400).send({ error: `${err}` }); + } + }); +} + +function rate_limit(server: express.Application): void { + // (suggested by LGTM): + // set up rate limiter -- maximum of 50 requests per minute + const limiter = new RateLimit({ + windowMs: 1 * 60 * 1000, // 1 minute + max: MAX_REQUESTS_PER_MINUTE + }); + // apply rate limiter to all requests + server.use(limiter); +} + +function handle_get(_req, res): void { + // Don't do anything useful, since user is not authenticated! + res.send({ status: "ok", mesg: "use a POST requesty" }); +} + +function handle_auth(req, secret_token: string): void { + const h = req.header("Authorization"); + if (h == null) { + throw Error("you MUST authenticate all requests via secret_token"); + } + + let provided_token: string; + const [type, user] = split(h); + switch (type) { + case "Bearer": + provided_token = user; + break; + case "Basic": + const x = Buffer.from(user, "base64"); + provided_token = x.toString().split(":")[0]; + break; + default: + throw Error(`unknown authorization type '${type}'`); + } + // now check auth + if (secret_token != provided_token) { + throw Error("incorrect secret_token"); + } +} + +async function handle_post(req, res, client: Client): Promise { + const endpoint: string = req.path.slice(req.path.lastIndexOf("/") + 1); + try { + switch (endpoint) { + case "get_syncdoc_history": + res.send(await get_syncdoc_history(req.body, client)); + return; + default: + throw Error("unknown endpoint"); + } + } catch (err) { + throw Error(`handling api endpoint ${endpoint} -- ${err}`); + } +} + +async function get_syncdoc_history(body, client: Client): Promise { + const dbg = client.dbg("get_syncdoc_history"); + let path = body.path; + dbg(`path="${path}"`); + if (typeof path != "string") { + throw Error("provide the path as a string"); + } + + // transform jupyter path -- TODO: this should + // be more centralized... since this is brittle. + if (endswith(path, ".ipynb")) { + path = meta_file(path, 'jupyter2') + } + + // compute the string_id + const string_id = client_db.sha1(client.project_id, path); + return await client.get_syncdoc_history(string_id, !!body.patches); +} diff --git a/src/smc-project/http-api/test.js b/src/smc-project/http-api/test.js new file mode 100644 index 0000000000..8ab7d59b91 --- /dev/null +++ b/src/smc-project/http-api/test.js @@ -0,0 +1,34 @@ +require("ts-node").register({ + project: __dirname + "/../tsconfig.json", + cacheDirectory: "/tmp" +}); + +require("coffeescript/register"); + +const client = { + secret_token: "secret", + project_id: "e11c1abe-52a0-4959-ac1a-391e14088bf5", + async get_syncdoc_history(string_id, patches) { + return [{ string_id, this_is_fake: true }]; + }, + dbg(name) { + return (...args) => { + console.log(name, ...args); + }; + } +}; + +async function start() { + try { + await require("./server.ts").start_server({ + port: 8080, + port_path: "/tmp/port", + client + }); + } catch (err) { + console.log(`EXCEPTION -- ${err}`); + console.trace(); + } +} + +start(); diff --git a/src/smc-project/local_hub.coffee b/src/smc-project/local_hub.coffee index 4b99fa530a..f8bd4fd9a3 100755 --- a/src/smc-project/local_hub.coffee +++ b/src/smc-project/local_hub.coffee @@ -67,6 +67,8 @@ print_to_pdf = require('./print_to_pdf') # Generation of the secret token used to auth tcp connections secret_token = require('./secret_token') +start_api_server = require('./http-api/server').start_server + # Console sessions console_session_manager = require('./console_session_manager') console_sessions = new console_session_manager.ConsoleSessions() @@ -335,7 +337,15 @@ start_server = (tcp_port, raw_port, cb) -> else the_secret_token = token console_sessions.set_secret_token(token) + exports.client.secret_token = token cb() + (cb) -> + winston.debug("start API server...") + try + await start_api_server({port_path:misc_node.abspath("#{DATA}/api_server.port"), client:exports.client}) + cb() + catch err + cb(err) (cb) -> winston.debug("starting tcp server...") start_tcp_server(the_secret_token, tcp_port, cb) @@ -374,3 +384,4 @@ else start_server program.tcp_port, program.raw_port, (err) -> if err process.exit(1) + diff --git a/src/smc-project/package.json b/src/smc-project/package.json index e95de77919..e1e8884907 100644 --- a/src/smc-project/package.json +++ b/src/smc-project/package.json @@ -4,7 +4,9 @@ "description": "CoCalc: project daemons", "main": "index.js", "dependencies": { + "@types/body-parser": "^1.17.0", "@types/expect": "^1.20.3", + "@types/express": "^4.16.1", "@types/jquery": "^3.3.6", "@types/mocha": "^5.2.5", "async": "^1.5.0", @@ -21,6 +23,7 @@ "decaffeinate": "^4.8.8", "expect": "^1.20.2", "express": "^4.13.3", + "express-rate-limit": "^3.4.0", "formidable": "^1.1.1", "googlediff": "^0.1.0", "json-stable-stringify": "^1.0.1", diff --git a/src/smc-project/raw_server.coffee b/src/smc-project/raw_server.coffee index 73e5fbea4a..f42ba5d2fd 100644 --- a/src/smc-project/raw_server.coffee +++ b/src/smc-project/raw_server.coffee @@ -37,7 +37,7 @@ exports.start_raw_server = (opts) -> raw_port_file = misc_node.abspath("#{data_path}/raw.port") raw_server = express() - http_server = require('http').createServer(raw_server); + http_server = require('http').createServer(raw_server); # suggested by http://expressjs.com/en/advanced/best-practice-performance.html#use-gzip-compression compression = require('compression') diff --git a/src/smc-util/client.coffee b/src/smc-util/client.coffee index 7e12edf2c0..106f928729 100644 --- a/src/smc-util/client.coffee +++ b/src/smc-util/client.coffee @@ -2104,6 +2104,20 @@ class exports.Connection extends EventEmitter throw Error("project_id must be a valid uuid") return (await @project_websocket(project_id)).api.capabilities() + syncdoc_history: (opts) => + opts = defaults opts, + string_id : required + patches : false + cb : required + @call + message : message.get_syncdoc_history(string_id:opts.string_id, patches:opts.patches) + error_event: true + allow_post : false + cb : (err, resp) => + if err + opts.cb(err) + else + opts.cb(undefined, resp.history) ################################################# # Other account Management functionality shared between client and server ################################################# diff --git a/src/smc-util/message.js b/src/smc-util/message.js index 73f159f116..f5931fb273 100644 --- a/src/smc-util/message.js +++ b/src/smc-util/message.js @@ -121,7 +121,7 @@ function message(obj) { return defaults(opts, obj, false, strict); }; return obj; -}; +} // message2 for "version 2" of the message definitions // TODO document it, for now just search for "message2" to see examples @@ -134,7 +134,7 @@ function message2(obj) { desc += ` (default: ${misc.to_json(val.init)})`; } return desc; - }; + } // reassembling a version 1 message from a version 2 message const mesg_v1 = _.mapObject(obj.fields, val => val.init); @@ -150,7 +150,7 @@ function message2(obj) { // wrapped version 1 message message(mesg_v1); return obj; -}; +} // messages that can be used by the HTTP api. {'event':true, ...} exports.api_messages = {}; @@ -2298,7 +2298,7 @@ API( }, tags: { init: undefined, - desc: "a list of tags, like \`['member']\`" + desc: "a list of tags, like `['member']`" }, account_id: { init: undefined, @@ -2965,4 +2965,19 @@ message({ path: required }); +// client --> hub +API( + message({ + event: "get_syncdoc_history", + id: undefined, + string_id: required, + patches: undefined + }) +); +// hub --> client +message({ + event: "syncdoc_history", + id: undefined, + history: required +}); diff --git a/src/smc-webapp/api-key.cjsx b/src/smc-webapp/api-key.cjsx index b5509a80a8..cdd98025c4 100644 --- a/src/smc-webapp/api-key.cjsx +++ b/src/smc-webapp/api-key.cjsx @@ -153,7 +153,7 @@ exports.APIKeySetting = rclass NOTE: If you do not have a password set, there is a workaround to generate your API key.

- See the CoCalc API documentation to learn about the API. + See the CoCalc API documentation to learn about the API.
diff --git a/src/smc-webapp/chat/input.tsx b/src/smc-webapp/chat/input.tsx index 9be7c29112..d9184ed433 100644 --- a/src/smc-webapp/chat/input.tsx +++ b/src/smc-webapp/chat/input.tsx @@ -3,14 +3,12 @@ import memoizeOne from "memoize-one"; import * as immutable from "immutable"; import { MentionsInput, Mention } from "react-mentions"; +import { USER_MENTION_MARKUP } from "./utils"; import { cmp_Date } from "smc-util/misc2"; const { Space } = require("../r_misc"); const { Avatar } = require("../other-users"); const { IS_MOBILE, isMobile } = require("../feature"); -const USER_MENTION_MARKUP = - '@__display__'; - interface Props { input: string; input_ref: any; @@ -19,6 +17,7 @@ interface Props { project_users: any; user_store: any; font_size: number; + height: string; on_paste?: (e) => void; on_change: (value, mentions) => void; on_send: (value) => void; @@ -30,12 +29,37 @@ interface Props { export class ChatInput extends React.PureComponent { static defaultProps = { enable_mentions: true, - font_size: 14 + font_size: 14, + height: "100%" }; - input_style = memoizeOne(font_size => { + private mentions_input_ref: any; + private input_ref: any; + + constructor(props) { + super(props); + this.mentions_input_ref = React.createRef(); + this.input_ref = props.input_ref || React.createRef(); + } + + // Hack around updating mentions when pasting an image (which we have to handle ourselves) + // Without this, MentionsInput does not correctly update its internal representation. + componentDidUpdate(prev_props) { + if ( + this.props.on_paste != undefined && + prev_props.input != this.props.input + ) { + window.setTimeout(() => { + this.mentions_input_ref.current.wrappedInstance.handleChange({ + target: this.input_ref.current + }); + }, 0); + } + } + + input_style = memoizeOne((font_size: number, height: string) => { return { - height: "100%", + height: height, "&multiLine": { highlighter: { @@ -141,10 +165,12 @@ export class ChatInput extends React.PureComponent { const user_array = this.mentions_data(this.props.project_users); const style = - this.props.input_style || this.input_style(this.props.font_size); + this.props.input_style || + this.input_style(this.props.font_size, this.props.height); return ( "@" + display} style={style} diff --git a/src/smc-webapp/chat/main.coffee b/src/smc-webapp/chat/main.coffee index f9f72d3e51..cfad70d9cc 100644 --- a/src/smc-webapp/chat/main.coffee +++ b/src/smc-webapp/chat/main.coffee @@ -1,6 +1,6 @@ info = require('./info') -{generate_name} = require('./util') +{generate_name} = require('./utils') # Not used or implemented yet exports.SideChat = require('./side_chat').SideChat diff --git a/src/smc-webapp/chat/register.cjsx b/src/smc-webapp/chat/register.cjsx index 3fd3214a1c..9e30dd3200 100644 --- a/src/smc-webapp/chat/register.cjsx +++ b/src/smc-webapp/chat/register.cjsx @@ -5,13 +5,13 @@ {webapp_client} = require('../webapp_client') # Sibling Libraries -util = require('./util') +utils = require('./utils') {ChatStore} = require('./store') {ChatActions} = require('./actions') {ChatRoom} = require('../smc_chat') exports.init = init = (path, redux, project_id) -> - name = util.generate_name(project_id, path) + name = utils.generate_name(project_id, path) if redux.getActions(name)? return name # already initialized @@ -40,7 +40,7 @@ exports.init = init = (path, redux, project_id) -> return name exports.remove = remove = (path, redux, project_id) -> - name = util.generate_name(project_id, path) + name = utils.generate_name(project_id, path) actions = redux.getActions(name) actions?.syncdb?.close() store = redux.getStore(name) @@ -54,7 +54,7 @@ exports.remove = remove = (path, redux, project_id) -> return name ChatEditorGenerator = (path, redux, project_id) -> - name = util.generate_name(project_id, path) + name = utils.generate_name(project_id, path) C_ChatRoom = ({actions}) -> ; + interface ChatState { height: number; // 0 means not rendered; otherwise is the height of the chat editor input: string; // content of the input box @@ -20,7 +28,7 @@ interface ChatState { is_saving: boolean; has_uncommitted_changes: boolean; has_unsaved_changes: boolean; - unsent_user_mentions: immutable.List<{ id: string; display: string }>; + unsent_user_mentions: MentionList; } export class ChatStore extends Store { diff --git a/src/smc-webapp/chat/util.coffee b/src/smc-webapp/chat/util.coffee deleted file mode 100644 index 8c100f7cab..0000000000 --- a/src/smc-webapp/chat/util.coffee +++ /dev/null @@ -1,5 +0,0 @@ - -exports.generate_name = (project_id, path) -> - return "editor-#{project_id}-#{path}" - - diff --git a/src/smc-webapp/chat/utils.ts b/src/smc-webapp/chat/utils.ts new file mode 100644 index 0000000000..440d297b90 --- /dev/null +++ b/src/smc-webapp/chat/utils.ts @@ -0,0 +1,65 @@ +import { MentionList } from "./store"; + +export function generate_name(project_id: string, path: string) { + return `editor-${project_id}-${path}`; +} + +export const USER_MENTION_MARKUP = + '@__display__'; + +const USER_MENTION_MARKUP_WITHOUT_PLACEHOLDERS = + ''; + +const SINGLE_MENTION_OFFSET = + USER_MENTION_MARKUP_WITHOUT_PLACEHOLDERS.length; + +/* + Given plain text which looks like + ``` + @person name you need to do this. + ``` + `cursor_plain_text_index` in that text, + and `mentions` from react-mentions, + + return the cursor position in the backing text which looks like + ``` + @person name you need to do this. + ``` +*/ +export function compute_cursor_offset_position( + cursor_plain_text_index: number, + mentions: MentionList +) { + let index_offset = 0; + let usuable_cursor_index = cursor_plain_text_index; + const mention_array = mentions.toJS(); + + for (let i = 0; i < mention_array.length; i++) { + const current_mention = mention_array[i]; + const { id, display, index, plainTextIndex } = current_mention; + const mention_offset = index - plainTextIndex; + + if (cursor_plain_text_index <= plainTextIndex) { + // Cursor is in front of this mention. ie. " asdfas |@jim" where | is the cursor + index_offset = mention_offset; + break; + } else if (cursor_plain_text_index >= plainTextIndex + display.length) { + if (i == mention_array.length - 1) { + // Cursor is after last mention. + index_offset = mention_offset + id.length + SINGLE_MENTION_OFFSET; + } + } else if (cursor_plain_text_index > plainTextIndex + display.length / 2) { + usuable_cursor_index = plainTextIndex + display.length; + if (i == mention_array.length - 1) { + // Cursor is inside the second half of the last mention. + index_offset = mention_offset + id.length + SINGLE_MENTION_OFFSET; + } + } else if (cursor_plain_text_index <= plainTextIndex + display.length / 2) { + // Cursor is inside the first half of this mention + usuable_cursor_index = plainTextIndex; + index_offset = mention_offset; + break; + } + } + return index_offset + usuable_cursor_index; +} diff --git a/src/smc-webapp/frame-editors/x11-editor/xpra-server.ts b/src/smc-webapp/frame-editors/x11-editor/xpra-server.ts index b08b54f81e..f289d24c76 100644 --- a/src/smc-webapp/frame-editors/x11-editor/xpra-server.ts +++ b/src/smc-webapp/frame-editors/x11-editor/xpra-server.ts @@ -108,7 +108,7 @@ export class XpraServer { `:${this.display}`, //"-d", //"all", - "--compression_level=9", + "--compression_level=1", "--socket-dir=/tmp/xpra", "--tray=no", "--mousewheel=on", diff --git a/src/smc-webapp/jupyter/actions.ts b/src/smc-webapp/jupyter/actions.ts index c67539a508..fe1831da99 100644 --- a/src/smc-webapp/jupyter/actions.ts +++ b/src/smc-webapp/jupyter/actions.ts @@ -120,13 +120,13 @@ export class JupyterActions extends Actions { public syncdb: any; public util: any; // TODO: check if this is used publicly - _init = async ( + _init = ( project_id: string, path: string, syncdb: any, store: any, client: any - ) => { + ): void => { if (project_id == null || path == null) { // typescript should ensure this, but just in case. throw Error("type error -- project_id and path can't be null"); @@ -1937,6 +1937,10 @@ export class JupyterActions extends Actions { }; set_kernel = (kernel: any) => { + if (this.syncdb.get_state() != 'ready') { + console.warn("Jupyter syncdb not yet ready -- not setting kernel"); + return; + } if (this.store.get("kernel") !== kernel) { this._set({ type: "settings", diff --git a/src/smc-webapp/jupyter/cell-output-message.tsx b/src/smc-webapp/jupyter/cell-output-message.tsx deleted file mode 100644 index d6197b172a..0000000000 --- a/src/smc-webapp/jupyter/cell-output-message.tsx +++ /dev/null @@ -1,801 +0,0 @@ -/* - * decaffeinate suggestions: - * DS102: Remove unnecessary code created because of implicit returns - * DS103: Rewrite code to no longer use __guard__ - * DS104: Avoid inline assignments - * DS202: Simplify dynamic range loops - * DS205: Consider reworking code to avoid use of IIFEs - * DS207: Consider shorter variations of null checks - * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md - */ -/* -Handling of output messages. - -TODO: most components should instead be in separate files. -*/ - -declare const $: any; - -import { React, Component } from "../app-framework"; // TODO: this will move -import { Button } from "react-bootstrap"; -import * as immutable from "immutable"; -const misc = require("smc-util/misc"); -const { Icon, Markdown, HTML } = require("../r_misc"); -// const { sanitize_html } = require("../misc_page"); -const Ansi = require("ansi-to-react"); -const { IFrame } = require("./cell-output-iframe"); -const { get_blob_url } = require("./server-urls"); -const { javascript_eval } = require("./javascript-eval"); -const { is_redux, is_redux_actions } = require("../app-framework"); - -const OUT_STYLE: React.CSSProperties = { - whiteSpace: "pre-wrap", - wordWrap: "break-word", - fontFamily: "monospace", - paddingTop: "5px", - paddingBottom: "5px", - paddingLeft: "5px" -}; - -// const ANSI_STYLE: React.CSSProperties = OUT_STYLE; -const STDOUT_STYLE: React.CSSProperties = OUT_STYLE; -const STDERR_STYLE: React.CSSProperties = misc.merge( - { backgroundColor: "#fdd" }, - STDOUT_STYLE -); -const TRACEBACK_STYLE: React.CSSProperties = misc.merge( - { backgroundColor: "#f9f2f4" }, - OUT_STYLE -); - -interface StdoutProps { - message: immutable.Map; -} - -export class Stdout extends Component { - shouldComponentUpdate(nextProps) { - return !immutable_equals(this.props, nextProps); - } - - render() { - const value = this.props.message.get("text"); - if (is_ansi(value)) { - return ( -
- {value} -
- ); - } - // This span below is solely to workaround an **ancient** Firefox bug - // See https://github.com/sagemathinc/cocalc/issues/1958 - return ( -
- {value} -
- ); - } -} - -interface StderrProps { - message: immutable.Map; -} - -export class Stderr extends Component { - shouldComponentUpdate(nextProps) { - return !immutable_equals(this.props, nextProps); - } - render() { - const value = this.props.message.get("text"); - if (is_ansi(value)) { - return ( -
- {value} -
- ); - } - // span below? what? -- See https://github.com/sagemathinc/cocalc/issues/1958 - return ( -
- {value} -
- ); - } -} - -interface ImageProps { - type: string; - sha1?: string; // one of sha1 or value should be given - value?: string; - project_id?: string; - width?: number; - height?: number; -} - -interface ImageState { - attempts: number; -} - -class Image extends Component { - private _is_mounted: any; // TODO: dont do this - - constructor(props: ImageProps, context: any) { - super(props, context); - this.state = { attempts: 0 }; - } - - load_error = () => { - if (this.state.attempts < 5 && this._is_mounted) { - const f = () => { - if (this._is_mounted) { - return this.setState({ attempts: this.state.attempts + 1 }); - } - }; - return setTimeout(f, 500); - } - }; - - componentDidMount() { - return (this._is_mounted = true); - } - - componentWillUnmount() { - return (this._is_mounted = false); - } - - extension = () => { - return this.props.type.split("/")[1].split("+")[0]; - }; - - render_using_server() { - const src = - get_blob_url(this.props.project_id, this.extension(), this.props.sha1) + - `&attempts=${this.state.attempts}`; - return ( - - ); - } - - encoding = () => { - switch (this.props.type) { - case "image/svg+xml": - return "utf8"; - default: - return "base64"; - } - }; - - render_locally() { - if (this.props.value == null) { - // should never happen - return ; - } - // The encodeURIComponent is definitely necessary these days. - // See https://github.com/sagemathinc/cocalc/issues/3197 and the comments at - // https://css-tricks.com/probably-dont-base64-svg/ - const src = `data:${ - this.props.type - };${this.encoding()},${encodeURIComponent(this.props.value)}`; - return ( - - ); - } - - render() { - if (this.props.value != null) { - return this.render_locally(); - } else if (this.props.sha1 != null && this.props.project_id != null) { - return this.render_using_server(); - } else { - // not enough info to render - return [unavailable {this.extension()} image]; - } - } -} - -interface TextPlainProps { - value: string; -} - -class TextPlain extends Component { - render() { - // span? what? -- See https://github.com/sagemathinc/cocalc/issues/1958 - return ( -
- {this.props.value} -
- ); - } -} - -interface UntrustedJavascriptProps { - value: any; // TODO: not used? -} - -class UntrustedJavascript extends Component { - render() { - return ( - (not running untrusted Javascript) - ); - } -} - -interface JavascriptProps { - value: any | string; // TODO: not used? -} - -class Javascript extends Component { - private node: HTMLElement; - componentDidMount() { - const element = $(this.node); - element.empty(); - let { value } = this.props; - if (typeof value !== "string") { - value = value.toJS(); - } - if (!misc.is_array(value)) { - value = [value]; - } - return value.map(line => javascript_eval(line, element)); - } - - render() { - return
(this.node = node)} />; - } -} - -interface PDFProps { - project_id?: string; - value: any | string; -} - -class PDF extends Component { - render() { - let href; - if (misc.is_string(this.props.value)) { - href = get_blob_url(this.props.project_id, "pdf", this.props.value); - } else { - const value = this.props.value.get("value"); - href = `data:application/pdf;base64,${value}`; - } - return ( - - ); - } -} - -interface DataProps { - message: immutable.Map; - project_id?: string; - directory?: string; - id?: string; - actions?: any; - trust?: boolean; -} - -class Data extends Component { - shouldComponentUpdate(nextProps) { - return !immutable_equals(this.props, nextProps); - } - render_html(value: any) { - return ( -
- -
- ); - } - render_markdown(value: any) { - return ( -
- -
- ); - } - render() { - let type: any = undefined; - let value: any = undefined; - const data = this.props.message.get("data"); - __guardMethod__(data, "forEach", o => - o.forEach(function(v, k) { - type = k; - value = v; - return false; - }) - ); - if (type) { - const [a, b] = type.split("/"); - switch (a) { - case "text": - switch (b) { - case "plain": - if (is_ansi(value)) { - return ( -
- {value} -
- ); - } - return ; - case "html": - case "latex": // put latex as HTML, since jupyter requires $'s anyways. - return this.render_html(value); - case "markdown": - return this.render_markdown(value); - } - break; - case "image": - let height: any; - let width: any; - this.props.message - .get("metadata", []) - .forEach((value: any, key: any) => { - if (key === "width") { - width = value; - } else if (key === "height") { - height = value; - } else { - // sometimes metadata is e.g., "image/png":{width:, height:} - if (value && value.forEach) { - value.forEach((value: any, key: any) => { - if (key === "width") { - return (width = value); - } else if (key === "height") { - return (height = value); - } - }); - } - } - }); - return ( - - ); - case "iframe": - return