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/customize.tsx
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
// Site Customize -- dynamically customize the look and configuration
7
// of CoCalc for the client.
8
9
import { fromJS, List, Map } from "immutable";
10
import { join } from "path";
11
import { useIntl } from "react-intl";
12
13
import {
14
Actions,
15
rclass,
16
React,
17
redux,
18
Redux,
19
rtypes,
20
Store,
21
TypedMap,
22
useTypedRedux,
23
} from "@cocalc/frontend/app-framework";
24
import {
25
A,
26
build_date,
27
Gap,
28
Loading,
29
r_join,
30
smc_git_rev,
31
smc_version,
32
UNIT,
33
} from "@cocalc/frontend/components";
34
import { getGoogleCloudImages, getImages } from "@cocalc/frontend/compute/api";
35
import { appBasePath } from "@cocalc/frontend/customize/app-base-path";
36
import { labels, Locale } from "@cocalc/frontend/i18n";
37
import { callback2, retry_until_success } from "@cocalc/util/async-utils";
38
import {
39
ComputeImage,
40
FALLBACK_ONPREM_ENV,
41
FALLBACK_SOFTWARE_ENV,
42
} from "@cocalc/util/compute-images";
43
import { DEFAULT_COMPUTE_IMAGE } from "@cocalc/util/db-schema";
44
import type {
45
GoogleCloudImages,
46
Images,
47
} from "@cocalc/util/db-schema/compute-servers";
48
import { LLMServicesAvailable } from "@cocalc/util/db-schema/llm-utils";
49
import {
50
Config,
51
KUCALC_COCALC_COM,
52
KUCALC_DISABLED,
53
KUCALC_ON_PREMISES,
54
site_settings_conf,
55
} from "@cocalc/util/db-schema/site-defaults";
56
import { deep_copy, dict, YEAR } from "@cocalc/util/misc";
57
import { reuseInFlight } from "@cocalc/util/reuse-in-flight";
58
import { sanitizeSoftwareEnv } from "@cocalc/util/sanitize-software-envs";
59
import * as theme from "@cocalc/util/theme";
60
import { CustomLLMPublic } from "@cocalc/util/types/llm";
61
import { DefaultQuotaSetting, Upgrades } from "@cocalc/util/upgrades/quota";
62
export { TermsOfService } from "@cocalc/frontend/customize/terms-of-service";
63
64
// this sets UI modes for using a kubernetes based back-end
65
// 'yes' (historic value) equals 'cocalc.com'
66
function validate_kucalc(k?): string {
67
if (k == null) return KUCALC_DISABLED;
68
const val = k.trim().toLowerCase();
69
if ([KUCALC_DISABLED, KUCALC_COCALC_COM, KUCALC_ON_PREMISES].includes(val)) {
70
return val;
71
}
72
console.warn(`site settings customize: invalid kucalc value ${k}`);
73
return KUCALC_DISABLED;
74
}
75
76
// populate all default key/values in the "customize" store
77
const defaultKeyVals: [string, string | string[]][] = [];
78
for (const k in site_settings_conf) {
79
const v: Config = site_settings_conf[k];
80
const value: any =
81
typeof v.to_val === "function" ? v.to_val(v.default) : v.default;
82
defaultKeyVals.push([k, value]);
83
}
84
const defaults: any = dict(defaultKeyVals);
85
defaults.is_commercial = defaults.commercial;
86
defaults._is_configured = false; // will be true after set via call to server
87
88
// CustomizeState is maybe extension of what's in SiteSettings
89
// so maybe there is a more clever way like this to do it than
90
// what I did below.
91
// type SiteSettings = { [k in keyof SiteSettingsConfig]: any };
92
93
export type SoftwareEnvironments = TypedMap<{
94
groups: List<string>;
95
default: string;
96
environments: Map<string, TypedMap<ComputeImage>>;
97
}>;
98
99
export interface CustomizeState {
100
time: number; // this will always get set once customize has loaded.
101
is_commercial: boolean;
102
openai_enabled: boolean;
103
google_vertexai_enabled: boolean;
104
mistral_enabled: boolean;
105
anthropic_enabled: boolean;
106
ollama_enabled: boolean;
107
custom_openai_enabled: boolean;
108
neural_search_enabled: boolean;
109
datastore: boolean;
110
ssh_gateway: boolean;
111
ssh_gateway_dns: string; // e.g. "ssh.cocalc.com"
112
ssh_gateway_fingerprint: string; // e.g. "SHA256:a8284..."
113
account_creation_email_instructions: string;
114
commercial: boolean;
115
default_quotas: TypedMap<DefaultQuotaSetting>;
116
dns: string; // e.g. "cocalc.com"
117
email_enabled: false;
118
email_signup: boolean;
119
anonymous_signup: boolean;
120
google_analytics: string;
121
help_email: string;
122
iframe_comm_hosts: string[];
123
index_info_html: string;
124
is_cocalc_com: boolean;
125
is_personal: boolean;
126
kucalc: string;
127
logo_rectangular: string;
128
logo_square: string;
129
max_upgrades: TypedMap<Partial<Upgrades>>;
130
131
// Commercialization parameters.
132
// Be sure to also update disableCommercializationParameters
133
// below if you change these:
134
nonfree_countries?: List<string>;
135
limit_free_project_uptime: number; // minutes
136
require_license_to_create_project?: boolean;
137
unlicensed_project_collaborator_limit?: number;
138
unlicensed_project_timetravel_limit?: number;
139
140
onprem_quota_heading: string;
141
organization_email: string;
142
organization_name: string;
143
organization_url: string;
144
share_server: boolean;
145
site_description: string;
146
site_name: string;
147
splash_image: string;
148
terms_of_service: string;
149
terms_of_service_url: string;
150
theming: boolean;
151
verify_emails: false;
152
version_min_browser: number;
153
version_min_project: number;
154
version_recommended_browser: number;
155
versions: string;
156
// extra setting, injected by the hub, not the DB
157
// we expect this to follow "ISO 3166-1 Alpha 2" + K1 (Tor network) + XX (unknown)
158
// use a lib like https://github.com/michaelwittig/node-i18n-iso-countries
159
country: string;
160
// flag to signal data stored in the Store.
161
software: SoftwareEnvironments;
162
_is_configured: boolean;
163
jupyter_api_enabled?: boolean;
164
165
compute_servers_enabled?: boolean;
166
["compute_servers_google-cloud_enabled"]?: boolean;
167
compute_servers_lambda_enabled?: boolean;
168
compute_servers_dns_enabled?: boolean;
169
compute_servers_dns?: string;
170
compute_servers_images?: TypedMap<Images> | string | null;
171
compute_servers_images_google?: TypedMap<GoogleCloudImages> | string | null;
172
173
llm_markup: number;
174
175
ollama?: TypedMap<{ [key: string]: TypedMap<CustomLLMPublic> }>;
176
custom_openai?: TypedMap<{ [key: string]: TypedMap<CustomLLMPublic> }>;
177
selectable_llms: List<string>;
178
default_llm?: string;
179
user_defined_llm: boolean;
180
181
insecure_test_mode?: boolean;
182
183
i18n?: List<Locale>;
184
}
185
186
export class CustomizeStore extends Store<CustomizeState> {
187
async until_configured(): Promise<void> {
188
if (this.get("_is_configured")) return;
189
await callback2(this.wait, { until: () => this.get("_is_configured") });
190
}
191
192
get_iframe_comm_hosts(): string[] {
193
const hosts = this.get("iframe_comm_hosts");
194
if (hosts == null) return [];
195
return hosts.toJS();
196
}
197
198
async getDefaultComputeImage(): Promise<string> {
199
await this.until_configured();
200
return this.getIn(["software", "default"]) ?? DEFAULT_COMPUTE_IMAGE;
201
}
202
203
getEnabledLLMs(): LLMServicesAvailable {
204
return {
205
openai: this.get("openai_enabled"),
206
google: this.get("google_vertexai_enabled"),
207
ollama: this.get("ollama_enabled"),
208
custom_openai: this.get("custom_openai_enabled"),
209
mistralai: this.get("mistral_enabled"),
210
anthropic: this.get("anthropic_enabled"),
211
user: this.get("user_defined_llm"),
212
};
213
}
214
}
215
216
export class CustomizeActions extends Actions<CustomizeState> {
217
// reload is admin only
218
updateComputeServerImages = reuseInFlight(async (reload?) => {
219
if (!store.get("compute_servers_enabled")) {
220
this.setState({ compute_servers_images: fromJS({}) as any });
221
return;
222
}
223
try {
224
this.setState({
225
compute_servers_images: fromJS(await getImages(reload)) as any,
226
});
227
} catch (err) {
228
this.setState({ compute_servers_images: `${err}` });
229
}
230
});
231
232
updateComputeServerImagesGoogle = reuseInFlight(async (reload?) => {
233
if (!store.get("compute_servers_google-cloud_enabled")) {
234
this.setState({ compute_servers_images_google: fromJS({}) as any });
235
return;
236
}
237
try {
238
this.setState({
239
compute_servers_images_google: fromJS(
240
await getGoogleCloudImages(reload),
241
) as any,
242
});
243
} catch (err) {
244
this.setState({ compute_servers_images_google: `${err}` });
245
}
246
});
247
248
// this is used for accounts that have legacy upgrades
249
disableCommercializationParameters = () => {
250
this.setState({
251
limit_free_project_uptime: undefined,
252
require_license_to_create_project: undefined,
253
unlicensed_project_collaborator_limit: undefined,
254
unlicensed_project_timetravel_limit: undefined,
255
});
256
};
257
}
258
259
export const store = redux.createStore("customize", CustomizeStore, defaults);
260
const actions = redux.createActions("customize", CustomizeActions);
261
// really simple way to have a default value -- gets changed below once the $?.get returns.
262
actions.setState({ is_commercial: true, ssh_gateway: true });
263
264
// If we are running in the browser, then we customize the schema. This also gets run on the backend
265
// to generate static content, which can't be customized.
266
export let commercial: boolean = defaults.is_commercial;
267
268
// For now, hopefully not used (this was the old approach).
269
// in the future we might want to reload the configuration, though.
270
// Note that this *is* clearly used as a fallback below though...!
271
async function init_customize() {
272
if (typeof process != "undefined") {
273
// running in node.js
274
return;
275
}
276
let customize;
277
await retry_until_success({
278
f: async () => {
279
const url = join(appBasePath, "customize");
280
try {
281
customize = await (await fetch(url)).json();
282
} catch (err) {
283
const msg = `fetch /customize failed -- retrying - ${err}`;
284
console.warn(msg);
285
throw new Error(msg);
286
}
287
},
288
start_delay: 2000,
289
max_delay: 30000,
290
});
291
292
const {
293
configuration,
294
registration,
295
strategies,
296
software = null,
297
ollama = null, // the derived public information
298
custom_openai = null,
299
} = customize;
300
process_kucalc(configuration);
301
process_software(software, configuration.is_cocalc_com);
302
process_customize(configuration); // this sets _is_configured to true
303
process_ollama(ollama);
304
process_custom_openai(custom_openai);
305
const actions = redux.getActions("account");
306
// Which account creation strategies we support.
307
actions.setState({ strategies });
308
// Set whether or not a registration token is required when creating account.
309
actions.setState({ token: !!registration });
310
}
311
312
init_customize();
313
314
function process_ollama(ollama?) {
315
if (!ollama) return;
316
actions.setState({ ollama: fromJS(ollama) });
317
}
318
319
function process_custom_openai(custom_openai?) {
320
if (!custom_openai) return;
321
actions.setState({ custom_openai: fromJS(custom_openai) });
322
}
323
324
function process_kucalc(obj) {
325
// TODO make this a to_val function in site_settings_conf.kucalc
326
obj.kucalc = validate_kucalc(obj.kucalc);
327
obj.is_cocalc_com = obj.kucalc == KUCALC_COCALC_COM;
328
}
329
330
function process_customize(obj) {
331
const obj_orig = deep_copy(obj);
332
for (const k in site_settings_conf) {
333
const v = site_settings_conf[k];
334
obj[k] =
335
obj[k] != null ? obj[k] : v.to_val?.(v.default, obj_orig) ?? v.default;
336
}
337
// the llm markup special case
338
obj.llm_markup = obj_orig._llm_markup ?? 30;
339
340
// always set time, so other code can know for sure that customize was loaded.
341
// it also might be helpful to know when
342
obj["time"] = Date.now();
343
set_customize(obj);
344
}
345
346
// "obj" are the already processed values from the database
347
// this function is also used by hub-landing!
348
function set_customize(obj) {
349
// console.log('set_customize obj=\n', JSON.stringify(obj, null, 2));
350
351
// set some special cases, backwards compatibility
352
commercial = obj.is_commercial = obj.commercial;
353
354
obj._is_configured = true;
355
actions.setState(obj);
356
}
357
358
function process_software(software, is_cocalc_com) {
359
const dbg = (...msg) => console.log("sanitizeSoftwareEnv:", ...msg);
360
if (software != null) {
361
// this checks the data coming in from the "/customize" endpoint.
362
// Next step is to convert it to immutable and store it in the customize store.
363
software = sanitizeSoftwareEnv({ software, purpose: "webapp" }, dbg);
364
actions.setState({ software });
365
} else {
366
if (is_cocalc_com) {
367
actions.setState({ software: fromJS(FALLBACK_SOFTWARE_ENV) as any });
368
} else {
369
software = sanitizeSoftwareEnv(
370
{ software: FALLBACK_ONPREM_ENV, purpose: "webapp" },
371
dbg,
372
);
373
actions.setState({ software });
374
}
375
}
376
}
377
378
interface HelpEmailLink {
379
text?: React.ReactNode;
380
color?: string;
381
}
382
383
export const HelpEmailLink: React.FC<HelpEmailLink> = React.memo(
384
(props: HelpEmailLink) => {
385
const { text, color } = props;
386
387
const help_email = useTypedRedux("customize", "help_email");
388
const _is_configured = useTypedRedux("customize", "_is_configured");
389
390
const style: React.CSSProperties = {};
391
if (color != null) {
392
style.color = color;
393
}
394
395
if (_is_configured) {
396
if (help_email?.length > 0) {
397
return (
398
<A href={`mailto:${help_email}`} style={style}>
399
{text ?? help_email}
400
</A>
401
);
402
} else {
403
return (
404
<span>
405
<em>
406
{"["}not configured{"]"}
407
</em>
408
</span>
409
);
410
}
411
} else {
412
return <Loading style={{ display: "inline" }} />;
413
}
414
},
415
);
416
417
export const SiteName: React.FC = React.memo(() => {
418
const site_name = useTypedRedux("customize", "site_name");
419
420
if (site_name != null) {
421
return <span>{site_name}</span>;
422
} else {
423
return <Loading style={{ display: "inline" }} />;
424
}
425
});
426
427
interface SiteDescriptionProps {
428
style?: React.CSSProperties;
429
site_description?: string;
430
}
431
432
const SiteDescription0 = rclass<{ style?: React.CSSProperties }>(
433
class SiteDescription extends React.Component<SiteDescriptionProps> {
434
public static reduxProps() {
435
return {
436
customize: {
437
site_description: rtypes.string,
438
},
439
};
440
}
441
442
public render(): JSX.Element {
443
const style =
444
this.props.style != undefined
445
? this.props.style
446
: { color: "#666", fontSize: "16px" };
447
if (this.props.site_description != undefined) {
448
return <span style={style}>{this.props.site_description}</span>;
449
} else {
450
return <Loading style={{ display: "inline" }} />;
451
}
452
}
453
},
454
);
455
456
// TODO: not used?
457
export function SiteDescription({ style }: { style?: React.CSSProperties }) {
458
return (
459
<Redux>
460
<SiteDescription0 style={style} />
461
</Redux>
462
);
463
}
464
465
// This generalizes the above in order to pick any selected string value
466
interface CustomizeStringProps {
467
name: string;
468
}
469
interface CustomizeStringReduxProps {
470
site_name: string;
471
site_description: string;
472
terms_of_service: string;
473
account_creation_email_instructions: string;
474
help_email: string;
475
logo_square: string;
476
logo_rectangular: string;
477
splash_image: string;
478
index_info_html: string;
479
terms_of_service_url: string;
480
organization_name: string;
481
organization_email: string;
482
organization_url: string;
483
google_analytics: string;
484
}
485
486
const CustomizeStringElement = rclass<CustomizeStringProps>(
487
class CustomizeStringComponent extends React.Component<
488
CustomizeStringReduxProps & CustomizeStringProps
489
> {
490
public static reduxProps = () => {
491
return {
492
customize: {
493
site_name: rtypes.string,
494
site_description: rtypes.string,
495
terms_of_service: rtypes.string,
496
account_creation_email_instructions: rtypes.string,
497
help_email: rtypes.string,
498
logo_square: rtypes.string,
499
logo_rectangular: rtypes.string,
500
splash_image: rtypes.string,
501
index_info_html: rtypes.string,
502
terms_of_service_url: rtypes.string,
503
organization_name: rtypes.string,
504
organization_email: rtypes.string,
505
organization_url: rtypes.string,
506
google_analytics: rtypes.string,
507
},
508
};
509
};
510
511
shouldComponentUpdate(next) {
512
if (this.props[this.props.name] == null) return true;
513
return this.props[this.props.name] != next[this.props.name];
514
}
515
516
render() {
517
return <span>{this.props[this.props.name]}</span>;
518
}
519
},
520
);
521
522
// TODO: not used?
523
export function CustomizeString({ name }: CustomizeStringProps) {
524
return (
525
<Redux>
526
<CustomizeStringElement name={name} />
527
</Redux>
528
);
529
}
530
531
// TODO also make this configurable? Needed in the <Footer/> and maybe elsewhere …
532
export const CompanyName = function CompanyName() {
533
return <span>{theme.COMPANY_NAME}</span>;
534
};
535
536
interface AccountCreationEmailInstructionsProps {
537
account_creation_email_instructions: string;
538
}
539
540
const AccountCreationEmailInstructions0 = rclass<{}>(
541
class AccountCreationEmailInstructions extends React.Component<AccountCreationEmailInstructionsProps> {
542
public static reduxProps = () => {
543
return {
544
customize: {
545
account_creation_email_instructions: rtypes.string,
546
},
547
};
548
};
549
550
render() {
551
return (
552
<h3 style={{ marginTop: 0, textAlign: "center" }}>
553
{this.props.account_creation_email_instructions}
554
</h3>
555
);
556
}
557
},
558
);
559
560
// TODO is this used?
561
export function AccountCreationEmailInstructions() {
562
return (
563
<Redux>
564
<AccountCreationEmailInstructions0 />
565
</Redux>
566
);
567
}
568
569
export const Footer: React.FC = React.memo(() => {
570
const intl = useIntl();
571
const on = useTypedRedux("customize", "organization_name");
572
const tos = useTypedRedux("customize", "terms_of_service_url");
573
574
const organizationName = on.length > 0 ? on : theme.COMPANY_NAME;
575
const TOSurl = tos.length > 0 ? tos : PolicyTOSPageUrl;
576
const webappVersionInfo =
577
`Version ${smc_version} @ ${build_date}` + ` | ${smc_git_rev.slice(0, 8)}`;
578
const style: React.CSSProperties = {
579
color: "gray",
580
textAlign: "center",
581
paddingBottom: `${UNIT}px`,
582
};
583
584
const systemStatus = intl.formatMessage({
585
id: "customize.footer.system-status",
586
defaultMessage: "System Status",
587
});
588
589
const name = intl.formatMessage(
590
{
591
id: "customize.footer.name",
592
defaultMessage: "{name} by {organizationName}",
593
},
594
{
595
name: <SiteName />,
596
organizationName,
597
},
598
);
599
600
function contents() {
601
const elements = [
602
<A key="name" href={appBasePath}>
603
{name}
604
</A>,
605
<A key="status" href={SystemStatusUrl}>
606
{systemStatus}
607
</A>,
608
<A key="tos" href={TOSurl}>
609
{intl.formatMessage(labels.terms_of_service)}
610
</A>,
611
<HelpEmailLink key="help" />,
612
<span key="year" title={webappVersionInfo}>
613
&copy; {YEAR}
614
</span>,
615
];
616
return r_join(elements, <> &middot; </>);
617
}
618
619
return (
620
<footer style={style}>
621
<hr />
622
<Gap />
623
{contents()}
624
</footer>
625
);
626
});
627
628
// first step of centralizing these URLs in one place → collecting all such pages into one
629
// react-class with a 'type' prop is the next step (TODO)
630
// then consolidate this with the existing site-settings database (e.g. TOS above is one fixed HTML string with an anchor)
631
632
export const PolicyIndexPageUrl = join(appBasePath, "policies");
633
export const PolicyPricingPageUrl = join(appBasePath, "pricing");
634
export const PolicyPrivacyPageUrl = join(appBasePath, "policies/privacy");
635
export const PolicyCopyrightPageUrl = join(appBasePath, "policies/copyright");
636
export const PolicyTOSPageUrl = join(appBasePath, "policies/terms");
637
export const SystemStatusUrl = join(appBasePath, "info/status");
638
export const PAYGODocsUrl = "https://doc.cocalc.com/paygo.html";
639
640
// 1. Google analytics
641
async function setup_google_analytics(w) {
642
// init_analytics already makes sure store is configured
643
const ga4 = store.get("google_analytics");
644
if (!ga4) return;
645
646
// for commercial setup, enable conversion tracking...
647
// the gtag initialization
648
w.dataLayer = w.dataLayer || [];
649
w.gtag = function () {
650
w.dataLayer.push(arguments);
651
};
652
w.gtag("js", new Date());
653
w.gtag("config", `"${ga4}"`);
654
// load tagmanager
655
const gtag = w.document.createElement("script");
656
gtag.src = `https://www.googletagmanager.com/gtag/js?id=${ga4}`;
657
gtag.async = true;
658
gtag.defer = true;
659
w.document.getElementsByTagName("head")[0].appendChild(gtag);
660
}
661
662
// 2. CoCalc analytics
663
function setup_cocalc_analytics(w) {
664
// init_analytics already makes sure store is configured
665
const ctag = w.document.createElement("script");
666
ctag.src = join(appBasePath, "analytics.js?fqd=false");
667
ctag.async = true;
668
ctag.defer = true;
669
w.document.getElementsByTagName("head")[0].appendChild(ctag);
670
}
671
672
async function init_analytics() {
673
await store.until_configured();
674
if (!store.get("is_commercial")) return;
675
676
let w: any;
677
try {
678
w = window;
679
} catch (_err) {
680
// Make it so this code can be run on the backend...
681
return;
682
}
683
if (w?.document == null) {
684
// Double check that this code can be run on the backend (not in a browser).
685
// see https://github.com/sagemathinc/cocalc-landing/issues/2
686
return;
687
}
688
689
await setup_google_analytics(w);
690
await setup_cocalc_analytics(w);
691
}
692
693
init_analytics();
694
695