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.

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