CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place. Commercial Alternative to JupyterHub.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/client/project.ts
Views: 923
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/*
7
Functionality that mainly involves working with a specific project.
8
*/
9
10
import { join } from "path";
11
12
import { redux } from "@cocalc/frontend/app-framework";
13
import computeServers from "@cocalc/frontend/compute/manager";
14
import { appBasePath } from "@cocalc/frontend/customize/app-base-path";
15
import { dialogs } from "@cocalc/frontend/i18n";
16
import { getIntl } from "@cocalc/frontend/i18n/get-intl";
17
import { ipywidgetsGetBufferUrl } from "@cocalc/frontend/jupyter/server-urls";
18
import { allow_project_to_run } from "@cocalc/frontend/project/client-side-throttle";
19
import { ensure_project_running } from "@cocalc/frontend/project/project-start-warning";
20
import { API } from "@cocalc/frontend/project/websocket/api";
21
import { connection_to_project } from "@cocalc/frontend/project/websocket/connect";
22
import {
23
ProjectInfo,
24
project_info,
25
} from "@cocalc/frontend/project/websocket/project-info";
26
import {
27
ProjectStatus,
28
project_status,
29
} from "@cocalc/frontend/project/websocket/project-status";
30
import {
31
UsageInfoWS,
32
get_usage_info,
33
} from "@cocalc/frontend/project/websocket/usage-info";
34
import {
35
Configuration,
36
ConfigurationAspect,
37
} from "@cocalc/frontend/project_configuration";
38
import { HOME_ROOT } from "@cocalc/util/consts/files";
39
import type { ApiKey } from "@cocalc/util/db-schema/api-keys";
40
import {
41
isExecOptsBlocking,
42
type ExecOpts,
43
type ExecOutput,
44
} from "@cocalc/util/db-schema/projects";
45
import * as message from "@cocalc/util/message";
46
import {
47
coerce_codomain_to_numbers,
48
copy_without,
49
defaults,
50
encode_path,
51
is_valid_uuid_string,
52
required,
53
} from "@cocalc/util/misc";
54
import { reuseInFlight } from "@cocalc/util/reuse-in-flight";
55
import { DirectoryListingEntry } from "@cocalc/util/types";
56
import httpApi from "./api";
57
import { WebappClient } from "./client";
58
import { throttle } from "lodash";
59
60
export class ProjectClient {
61
private client: WebappClient;
62
private touch_throttle: { [project_id: string]: number } = {};
63
64
constructor(client: WebappClient) {
65
this.client = client;
66
}
67
68
private async call(message: object): Promise<any> {
69
return await this.client.async_call({ message });
70
}
71
72
public async write_text_file(opts: {
73
project_id: string;
74
path: string;
75
content: string;
76
}): Promise<void> {
77
return await this.call(message.write_text_file_to_project(opts));
78
}
79
80
public async read_text_file(opts: {
81
project_id: string; // string or array of strings
82
path: string; // string or array of strings
83
}): Promise<string> {
84
return (await this.call(message.read_text_file_from_project(opts))).content;
85
}
86
87
// Like "read_text_file" above, except the callback
88
// message gives a url from which the file can be
89
// downloaded using standard AJAX.
90
public read_file(opts: {
91
project_id: string; // string or array of strings
92
path: string; // string or array of strings
93
}): string {
94
const base_path = appBasePath;
95
if (opts.path[0] === "/") {
96
// absolute path to the root
97
opts.path = HOME_ROOT + opts.path; // use root symlink, which is created by start_smc
98
}
99
return encode_path(join(base_path, `${opts.project_id}/raw/${opts.path}`));
100
}
101
102
public async copy_path_between_projects(opts: {
103
public?: boolean; // used e.g., by share server landing page action.
104
src_project_id: string; // id of source project
105
src_path: string; // relative path of director or file in the source project
106
target_project_id: string; // if of target project
107
target_path?: string; // defaults to src_path
108
overwrite_newer?: boolean; // overwrite newer versions of file at destination (destructive)
109
delete_missing?: boolean; // delete files in dest that are missing from source (destructive)
110
backup?: boolean; // make ~ backup files instead of overwriting changed files
111
timeout?: number; // **timeout in seconds** -- how long to wait for the copy to complete before reporting "error" (though it could still succeed)
112
exclude?: string[]; // list of patterns to exclude; this uses exactly the (confusing) rsync patterns
113
}): Promise<void> {
114
const is_public = opts.public;
115
delete opts.public;
116
117
if (opts.target_path == null) {
118
opts.target_path = opts.src_path;
119
}
120
121
const mesg = is_public
122
? message.copy_public_path_between_projects(opts)
123
: message.copy_path_between_projects(opts);
124
mesg.wait_until_done = true; // TODO: our UI only supports this for now.
125
126
// THIS CAN BE USEFUL FOR DEBUGGING!
127
// mesg.debug_delay_s = 10;
128
129
await this.client.async_call({
130
timeout: opts.timeout,
131
message: mesg,
132
allow_post: false, // since it may take too long
133
});
134
}
135
136
// Set a quota parameter for a given project.
137
// As of now, only user in the admin group can make these changes.
138
public async set_quotas(opts: {
139
project_id: string;
140
memory?: number; // see message.js for the units, etc., for all these settings
141
memory_request?: number;
142
cpu_shares?: number;
143
cores?: number;
144
disk_quota?: number;
145
mintime?: number;
146
network?: number;
147
member_host?: number;
148
always_running?: number;
149
}): Promise<void> {
150
// we do some extra work to ensure all the quotas are numbers (typescript isn't
151
// enough; sometimes client code provides strings, which can cause lots of trouble).
152
const x = coerce_codomain_to_numbers(copy_without(opts, ["project_id"]));
153
await this.call(
154
message.project_set_quotas({ ...x, ...{ project_id: opts.project_id } }),
155
);
156
}
157
158
public async websocket(project_id: string): Promise<any> {
159
const store = redux.getStore("projects");
160
// Wait until project is running (or admin and not on project)
161
await store.async_wait({
162
until: () => {
163
const state = store.get_state(project_id);
164
if (state == null && redux.getStore("account")?.get("is_admin")) {
165
// is admin so doesn't know project state -- just immediately
166
// try, which will cause project to run
167
return true;
168
}
169
return state == "running";
170
},
171
});
172
173
// get_my_group returns undefined when the various info to
174
// determine this isn't yet loaded. For some connections
175
// this websocket function gets called before that info is
176
// loaded, which can cause trouble.
177
let group: string | undefined;
178
await store.async_wait({
179
until: () => (group = store.get_my_group(project_id)) != null,
180
});
181
if (group == "public") {
182
throw Error("no access to project websocket");
183
}
184
return await connection_to_project(project_id);
185
}
186
187
public async api(project_id: string): Promise<API> {
188
return (await this.websocket(project_id)).api;
189
}
190
191
/*
192
Execute code in a given project or associated compute server.
193
194
Aggregate option -- use like this:
195
196
webapp.exec
197
aggregate: timestamp (or something else sequential)
198
199
means: if there are multiple attempts to run the given command with the same
200
time, they are all aggregated and run only one time by the project. If requests
201
comes in with a newer time, they all run in another group after the first
202
one finishes. The timestamp will usually come from something like the "last save
203
time" (which is stored in the db), which they client will know. This is used, e.g.,
204
for operations like "run rst2html on this file whenever it is saved."
205
*/
206
public async exec(opts: ExecOpts & { post?: boolean }): Promise<ExecOutput> {
207
if ("async_get" in opts) {
208
opts = defaults(opts, {
209
project_id: required,
210
compute_server_id: undefined,
211
async_get: required,
212
async_stats: undefined,
213
async_await: undefined,
214
post: false, // if true, uses the POST api through nextjs instead of the websocket api.
215
timeout: 30,
216
cb: undefined,
217
});
218
} else {
219
opts = defaults(opts, {
220
project_id: required,
221
compute_server_id: undefined,
222
filesystem: undefined,
223
path: "",
224
command: required,
225
args: [],
226
max_output: undefined,
227
bash: false,
228
aggregate: undefined,
229
err_on_exit: true,
230
env: undefined,
231
post: false, // if true, uses the POST api through nextjs instead of the websocket api.
232
async_call: undefined, // if given use a callback interface instead of async
233
timeout: 30,
234
cb: undefined,
235
});
236
}
237
238
const intl = await getIntl();
239
const msg = intl.formatMessage(dialogs.client_project_exec_msg, {
240
blocking: isExecOptsBlocking(opts),
241
arg: isExecOptsBlocking(opts) ? opts.command : opts.async_get,
242
});
243
244
if (!(await ensure_project_running(opts.project_id, msg))) {
245
return {
246
type: "blocking",
247
stdout: "",
248
stderr: intl.formatMessage(dialogs.client_project_exec_start_first),
249
exit_code: 1,
250
time: 0,
251
};
252
}
253
254
const { post } = opts;
255
delete opts.post;
256
257
try {
258
let msg;
259
if (post) {
260
// use post API
261
msg = await httpApi("exec", opts);
262
} else {
263
const ws = await this.websocket(opts.project_id);
264
const exec_opts = copy_without(opts, ["project_id"]);
265
msg = await ws.api.exec(exec_opts);
266
}
267
if (msg.status && msg.status == "error") {
268
throw new Error(msg.error);
269
}
270
if (msg.type === "blocking") {
271
delete msg.status;
272
}
273
delete msg.error;
274
if (opts.cb == null) {
275
return msg;
276
} else {
277
opts.cb(undefined, msg);
278
return msg;
279
}
280
} catch (err) {
281
if (opts.cb == null) {
282
throw err;
283
} else {
284
if (!err.message) {
285
// Important since err.message can be falsey, e.g., for Error(''), but toString will never be falsey.
286
opts.cb(err.toString());
287
} else {
288
opts.cb(err.message);
289
}
290
return {
291
type: "blocking",
292
stdout: "",
293
stderr: err.message,
294
exit_code: 1,
295
time: 0, // should be ignored; this is just to make typescript happy.
296
};
297
}
298
}
299
}
300
301
// Directly compute the directory listing. No caching or other information
302
// is used -- this just sends a message over the websocket requesting
303
// the backend node.js project process to compute the listing.
304
public async directory_listing(opts: {
305
project_id: string;
306
path: string;
307
compute_server_id: number;
308
timeout?: number;
309
hidden?: boolean;
310
}): Promise<{ files: DirectoryListingEntry[] }> {
311
if (opts.timeout == null) opts.timeout = 15;
312
const api = await this.api(opts.project_id);
313
const listing = await api.listing(
314
opts.path,
315
opts.hidden,
316
opts.timeout * 1000,
317
opts.compute_server_id,
318
);
319
return { files: listing };
320
}
321
322
public async public_get_text_file(opts: {
323
project_id: string;
324
path: string;
325
}): Promise<string> {
326
return (await this.call(message.public_get_text_file(opts))).data;
327
}
328
329
public async find_directories(opts: {
330
project_id: string;
331
query?: string; // see the -iwholename option to the UNIX find command.
332
path?: string; // Root path to find directories from
333
exclusions?: string[]; // paths relative to `opts.path`. Skips whole sub-trees
334
include_hidden?: boolean;
335
}): Promise<{
336
query: string;
337
path: string;
338
project_id: string;
339
directories: string[];
340
}> {
341
opts = defaults(opts, {
342
project_id: required,
343
query: "*", // see the -iwholename option to the UNIX find command.
344
path: ".", // Root path to find directories from
345
exclusions: undefined, // Array<String> Paths relative to `opts.path`. Skips whole sub-trees
346
include_hidden: false,
347
});
348
if (opts.path == null || opts.query == null)
349
throw Error("bug -- cannot happen");
350
351
const args: string[] = [
352
opts.path,
353
"-xdev",
354
"!",
355
"-readable",
356
"-prune",
357
"-o",
358
"-type",
359
"d",
360
"-iwholename", // See https://github.com/sagemathinc/cocalc/issues/5502
361
`'${opts.query}'`,
362
"-readable",
363
];
364
if (opts.exclusions != null) {
365
for (const excluded_path of opts.exclusions) {
366
args.push(
367
`-a -not \\( -path '${opts.path}/${excluded_path}' -prune \\)`,
368
);
369
}
370
}
371
372
args.push("-print");
373
const command = `find ${args.join(" ")}`;
374
375
const result = await this.exec({
376
// err_on_exit = false: because want this to still work even if there's a nonzero exit code,
377
// which might happen if find hits a directory it can't read, e.g., a broken ~/.snapshots.
378
err_on_exit: false,
379
project_id: opts.project_id,
380
command,
381
timeout: 60,
382
aggregate: Math.round(Date.now() / 5000), // aggregate calls into 5s windows, in case multiple clients ask for same find at once...
383
});
384
const n = opts.path.length + 1;
385
let v = result.stdout.split("\n");
386
if (!opts.include_hidden) {
387
v = v.filter((x) => x.indexOf("/.") === -1);
388
}
389
v = v.filter((x) => x.length > n).map((x) => x.slice(n));
390
return {
391
query: opts.query,
392
path: opts.path,
393
project_id: opts.project_id,
394
directories: v,
395
};
396
}
397
398
// This is async, so do "await smc_webapp.configuration(...project_id...)".
399
// for reuseInFlight, see https://github.com/sagemathinc/cocalc/issues/7806
400
public configuration = reuseInFlight(
401
async (
402
project_id: string,
403
aspect: ConfigurationAspect,
404
no_cache: boolean,
405
): Promise<Configuration> => {
406
if (!is_valid_uuid_string(project_id)) {
407
throw Error("project_id must be a valid uuid");
408
}
409
return (await this.api(project_id)).configuration(aspect, no_cache);
410
},
411
);
412
413
// Remove all upgrades from all projects that this user collaborates on.
414
public async remove_all_upgrades(projects?: string[]): Promise<void> {
415
await this.call(message.remove_all_upgrades({ projects }));
416
}
417
418
touch_project = async (
419
// project_id where activity occured
420
project_id: string,
421
// optional global id of a compute server (in the given project), in which case we also mark
422
// that compute server as active, which keeps it running in case it has idle timeout configured.
423
compute_server_id?: number,
424
): Promise<void> => {
425
if (compute_server_id) {
426
// this is throttled, etc. and is independent of everything below.
427
touchComputeServer({
428
project_id,
429
compute_server_id,
430
client: this.client,
431
});
432
// that said, we do still touch the project, since if a user is actively
433
// using a compute server, the project should also be considered active.
434
}
435
436
const state = redux.getStore("projects")?.get_state(project_id);
437
if (!(state == null && redux.getStore("account")?.get("is_admin"))) {
438
// not trying to view project as admin so do some checks
439
if (!(await allow_project_to_run(project_id))) return;
440
if (!this.client.is_signed_in()) {
441
// silently ignore if not signed in
442
return;
443
}
444
if (state != "running") {
445
// not running so don't touch (user must explicitly start first)
446
return;
447
}
448
}
449
450
// Throttle -- so if this function is called with the same project_id
451
// twice in 3s, it's ignored (to avoid unnecessary network traffic).
452
// Do not make the timeout long, since that can mess up
453
// getting the hub-websocket to connect to the project.
454
const last = this.touch_throttle[project_id];
455
if (last != null && Date.now() - last <= 3000) {
456
return;
457
}
458
this.touch_throttle[project_id] = Date.now();
459
try {
460
await this.call(message.touch_project({ project_id }));
461
} catch (err) {
462
// silently ignore; this happens, e.g., if you touch too frequently,
463
// and shouldn't be fatal and break other things.
464
// NOTE: this is a bit ugly for now -- basically the
465
// hub returns an error regarding actually touching
466
// the project (updating the db), but it still *does*
467
// ensure there is a TCP connection to the project.
468
}
469
};
470
471
// Print file to pdf
472
// The printed version of the file will be created in the same directory
473
// as path, but with extension replaced by ".pdf".
474
// Only used for sagews, and would be better done with websocket api anyways...
475
public async print_to_pdf(opts: {
476
project_id: string;
477
path: string;
478
options?: any; // optional options that get passed to the specific backend for this file type
479
timeout?: number; // client timeout -- some things can take a long time to print!
480
}): Promise<string> {
481
// returns path to pdf file
482
if (opts.options == null) opts.options = {};
483
opts.options.timeout = opts.timeout; // timeout on backend
484
485
return (
486
await this.client.async_call({
487
message: message.local_hub({
488
project_id: opts.project_id,
489
message: message.print_to_pdf({
490
path: opts.path,
491
options: opts.options,
492
}),
493
}),
494
timeout: opts.timeout,
495
allow_post: false,
496
})
497
).path;
498
}
499
500
public async create(opts: {
501
title: string;
502
description: string;
503
image?: string;
504
start?: boolean;
505
license?: string; // "license_id1,license_id2,..." -- if given, create project with these licenses applied
506
noPool?: boolean; // never use pool
507
}): Promise<string> {
508
const { project_id } = await this.client.async_call({
509
allow_post: false, // since gets called for anonymous and cookie not yet set.
510
message: message.create_project(opts),
511
});
512
513
this.client.tracking_client.user_tracking("create_project", {
514
project_id,
515
title: opts.title,
516
});
517
518
return project_id;
519
}
520
521
// Disconnect whatever hub we are connected to from the project
522
// Adding this right now only for debugging/dev purposes!
523
public async disconnect_hub_from_project(project_id: string): Promise<void> {
524
await this.call(message.disconnect_from_project({ project_id }));
525
}
526
527
public async realpath(opts: {
528
project_id: string;
529
path: string;
530
}): Promise<string> {
531
const real = (await this.api(opts.project_id)).realpath(opts.path);
532
return real;
533
}
534
535
async isdir({
536
project_id,
537
path,
538
}: {
539
project_id: string;
540
path: string;
541
}): Promise<boolean> {
542
const { stdout, exit_code } = await this.exec({
543
project_id,
544
command: "file",
545
args: ["-Eb", path],
546
err_on_exit: false,
547
});
548
return !exit_code && stdout.trim() == "directory";
549
}
550
551
// Add and remove a license from a project. Note that these
552
// might not be used to implement anything in the client frontend, but
553
// are used via the API, and this is a convenient way to test them.
554
public async add_license_to_project(
555
project_id: string,
556
license_id: string,
557
): Promise<void> {
558
await this.call(message.add_license_to_project({ project_id, license_id }));
559
}
560
561
public async remove_license_from_project(
562
project_id: string,
563
license_id: string,
564
): Promise<void> {
565
await this.call(
566
message.remove_license_from_project({ project_id, license_id }),
567
);
568
}
569
570
public project_info(project_id: string): ProjectInfo {
571
return project_info(this.client, project_id);
572
}
573
574
public project_status(project_id: string): ProjectStatus {
575
return project_status(this.client, project_id);
576
}
577
578
public usage_info(project_id: string): UsageInfoWS {
579
return get_usage_info(project_id);
580
}
581
582
public ipywidgetsGetBuffer = reuseInFlight(
583
async (
584
project_id: string,
585
path: string,
586
model_id: string,
587
buffer_path: string,
588
useHttp?: boolean, // ONLY works for home base, NOT compute servers!
589
): Promise<ArrayBuffer> => {
590
if (useHttp) {
591
const url = ipywidgetsGetBufferUrl(
592
project_id,
593
path,
594
model_id,
595
buffer_path,
596
);
597
return await (await fetch(url)).arrayBuffer();
598
}
599
const actions = redux.getEditorActions(project_id, path);
600
return await actions.jupyter_actions.ipywidgetsGetBuffer(
601
model_id,
602
buffer_path,
603
);
604
},
605
);
606
607
// getting, setting, editing, deleting, etc., the api keys for a project
608
public async api_keys(opts: {
609
project_id: string;
610
action: "get" | "delete" | "create" | "edit";
611
password?: string;
612
name?: string;
613
id?: number;
614
expire?: Date;
615
}): Promise<ApiKey[] | undefined> {
616
if (this.client.account_id == null) {
617
throw Error("must be logged in");
618
}
619
if (!is_valid_uuid_string(opts.project_id)) {
620
throw Error("project_id must be a valid uuid");
621
}
622
if (opts.project_id == null && !opts.password) {
623
throw Error("must provide password for non-project api key");
624
}
625
// because message always uses id, so we have to use something else!
626
const opts2: any = { ...opts };
627
delete opts2.id;
628
opts2.key_id = opts.id;
629
return (await this.call(message.api_keys(opts2))).response;
630
}
631
632
computeServers = (project_id) => {
633
return computeServers(project_id);
634
};
635
636
getServerIdForPath = async ({
637
project_id,
638
path,
639
}): Promise<number | undefined> => {
640
return await computeServers(project_id)?.getServerIdForPath(path);
641
};
642
}
643
644
// (NOTE: this won't throw an exception)
645
const touchComputeServer = throttle(
646
async ({ project_id, compute_server_id, client }) => {
647
if (!compute_server_id) {
648
// nothing to do
649
return;
650
}
651
try {
652
await client.async_query({
653
query: {
654
compute_servers: {
655
project_id,
656
id: compute_server_id,
657
last_edited_user: client.server_time(),
658
},
659
},
660
});
661
} catch (err) {
662
// just a warning -- if we can't connect then touching isn't something we should be doing anyways.
663
console.log(
664
"WARNING: failed to touch compute server -- ",
665
{ compute_server_id },
666
err,
667
);
668
}
669
},
670
30000,
671
);
672
673