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