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