Path: blob/master/src/packages/frontend/customize.tsx
5765 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45// Site Customize -- dynamically customize the look and configuration6// of CoCalc for the client.78// cSpell:ignore TOSurl PAYGO nonfree tagmanager910import { fromJS, List, Map } from "immutable";11import { join } from "path";12import { useIntl } from "react-intl";1314import {15Actions,16rclass,17React,18redux,19Redux,20rtypes,21Store,22TypedMap,23useTypedRedux,24} from "@cocalc/frontend/app-framework";25import {26A,27build_date,28Gap,29Loading,30r_join,31smc_git_rev,32smc_version,33UNIT,34} from "@cocalc/frontend/components";35import { getGoogleCloudImages, getImages } from "@cocalc/frontend/compute/api";36import { appBasePath } from "@cocalc/frontend/customize/app-base-path";37import { labels, Locale } from "@cocalc/frontend/i18n";38import { callback2, retry_until_success } from "@cocalc/util/async-utils";39import {40ComputeImage,41FALLBACK_ONPREM_ENV,42FALLBACK_SOFTWARE_ENV,43} from "@cocalc/util/compute-images";44import { DEFAULT_COMPUTE_IMAGE } from "@cocalc/util/db-schema";45import type {46GoogleCloudImages,47Images,48} from "@cocalc/util/db-schema/compute-servers";49import { LLMServicesAvailable } from "@cocalc/util/db-schema/llm-utils";50import {51Config,52KUCALC_COCALC_COM,53KUCALC_DISABLED,54KUCALC_ON_PREMISES,55site_settings_conf,56} from "@cocalc/util/db-schema/site-defaults";57import { deep_copy, dict, YEAR } from "@cocalc/util/misc";58import { reuseInFlight } from "@cocalc/util/reuse-in-flight";59import { sanitizeSoftwareEnv } from "@cocalc/util/sanitize-software-envs";60import * as theme from "@cocalc/util/theme";61import { CustomLLMPublic } from "@cocalc/util/types/llm";62import { DefaultQuotaSetting, Upgrades } from "@cocalc/util/upgrades/quota";63export { TermsOfService } from "@cocalc/frontend/customize/terms-of-service";64import { delay } from "awaiting";6566// update every 2 minutes.67const UPDATE_INTERVAL = 2 * 60000;6869// this sets UI modes for using a kubernetes based back-end70// 'yes' (historic value) equals 'cocalc.com'71function validate_kucalc(k?): string {72if (k == null) return KUCALC_DISABLED;73const val = k.trim().toLowerCase();74if ([KUCALC_DISABLED, KUCALC_COCALC_COM, KUCALC_ON_PREMISES].includes(val)) {75return val;76}77console.warn(`site settings customize: invalid kucalc value ${k}`);78return KUCALC_DISABLED;79}8081// populate all default key/values in the "customize" store82const defaultKeyVals: [string, string | string[]][] = [];83for (const k in site_settings_conf) {84const v: Config = site_settings_conf[k];85const value: any =86typeof v.to_val === "function" ? v.to_val(v.default) : v.default;87defaultKeyVals.push([k, value]);88}89const defaults: any = dict(defaultKeyVals);90defaults.is_commercial = defaults.commercial;91defaults._is_configured = false; // will be true after set via call to server9293// CustomizeState is maybe extension of what's in SiteSettings94// so maybe there is a more clever way like this to do it than95// what I did below.96// type SiteSettings = { [k in keyof SiteSettingsConfig]: any };9798export type SoftwareEnvironments = TypedMap<{99groups: List<string>;100default: string;101environments: Map<string, TypedMap<ComputeImage>>;102}>;103104export interface CustomizeState {105time: number; // this will always get set once customize has loaded.106is_commercial: boolean;107openai_enabled: boolean;108google_vertexai_enabled: boolean;109mistral_enabled: boolean;110anthropic_enabled: boolean;111xai_enabled: boolean;112ollama_enabled: boolean;113custom_openai_enabled: boolean;114neural_search_enabled: boolean;115datastore: boolean;116ssh_gateway: boolean;117ssh_gateway_dns: string; // e.g. "ssh.cocalc.com"118ssh_gateway_fingerprint: string; // e.g. "SHA256:a8284..."119account_creation_email_instructions: string;120commercial: boolean;121default_quotas: TypedMap<DefaultQuotaSetting>;122dns: string; // e.g. "cocalc.com"123email_enabled: false;124email_signup: boolean;125anonymous_signup: boolean;126google_analytics: string;127help_email: string;128iframe_comm_hosts: string[];129index_info_html: string;130is_cocalc_com: boolean;131is_personal: boolean;132kucalc: string;133logo_rectangular: string;134logo_square: string;135max_upgrades: TypedMap<Partial<Upgrades>>;136137// Commercialization parameters.138// Be sure to also update disableCommercializationParameters139// below if you change these:140nonfree_countries?: List<string>;141limit_free_project_uptime: number; // minutes142require_license_to_create_project?: boolean;143unlicensed_project_collaborator_limit?: number;144unlicensed_project_timetravel_limit?: number;145146onprem_quota_heading: string;147organization_email: string;148organization_name: string;149organization_url: string;150share_server: boolean;151strict_collaborator_management: boolean;152site_description: string;153site_name: string;154splash_image: string;155terms_of_service: string;156terms_of_service_url: string;157theming: boolean;158verify_emails: false;159version_min_browser: number;160version_min_project: number;161version_recommended_browser: number;162versions: string;163// extra setting, injected by the hub, not the DB164// we expect this to follow "ISO 3166-1 Alpha 2" + K1 (Tor network) + XX (unknown)165// use a lib like https://github.com/michaelwittig/node-i18n-iso-countries166country: string;167// flag to signal data stored in the Store.168software: SoftwareEnvironments;169_is_configured: boolean;170jupyter_api_enabled?: boolean;171172compute_servers_enabled?: boolean;173["compute_servers_google-cloud_enabled"]?: boolean;174compute_servers_lambda_enabled?: boolean;175compute_servers_dns_enabled?: boolean;176compute_servers_dns?: string;177compute_servers_images?: TypedMap<Images> | string | null;178compute_servers_images_google?: TypedMap<GoogleCloudImages> | string | null;179180llm_markup: number;181182ollama?: TypedMap<{ [key: string]: TypedMap<CustomLLMPublic> }>;183custom_openai?: TypedMap<{ [key: string]: TypedMap<CustomLLMPublic> }>;184selectable_llms: List<string>;185default_llm?: string;186user_defined_llm: boolean;187llm_default_quota?: number;188189insecure_test_mode?: boolean;190191i18n?: List<Locale>;192193user_tracking?: string;194}195196export class CustomizeStore extends Store<CustomizeState> {197async until_configured(): Promise<void> {198if (this.get("_is_configured")) return;199await callback2(this.wait, { until: () => this.get("_is_configured") });200}201202get_iframe_comm_hosts(): string[] {203const hosts = this.get("iframe_comm_hosts");204if (hosts == null) return [];205return hosts.toJS();206}207208async getDefaultComputeImage(): Promise<string> {209await this.until_configured();210return this.getIn(["software", "default"]) ?? DEFAULT_COMPUTE_IMAGE;211}212213getEnabledLLMs(): LLMServicesAvailable {214return {215openai: this.get("openai_enabled"),216google: this.get("google_vertexai_enabled"),217ollama: this.get("ollama_enabled"),218custom_openai: this.get("custom_openai_enabled"),219mistralai: this.get("mistral_enabled"),220anthropic: this.get("anthropic_enabled"),221xai: this.get("xai_enabled"),222user: this.get("user_defined_llm"),223};224}225}226227export class CustomizeActions extends Actions<CustomizeState> {228// reload is admin only229updateComputeServerImages = reuseInFlight(async (reload?) => {230if (!store.get("compute_servers_enabled")) {231this.setState({ compute_servers_images: fromJS({}) as any });232return;233}234try {235this.setState({236compute_servers_images: fromJS(await getImages(reload)) as any,237});238} catch (err) {239this.setState({ compute_servers_images: `${err}` });240}241});242243updateComputeServerImagesGoogle = reuseInFlight(async (reload?) => {244if (!store.get("compute_servers_google-cloud_enabled")) {245this.setState({ compute_servers_images_google: fromJS({}) as any });246return;247}248try {249this.setState({250compute_servers_images_google: fromJS(251await getGoogleCloudImages(reload),252) as any,253});254} catch (err) {255this.setState({ compute_servers_images_google: `${err}` });256}257});258259// this is used for accounts that have legacy upgrades260disableCommercializationParameters = () => {261this.setState({262limit_free_project_uptime: undefined,263require_license_to_create_project: undefined,264unlicensed_project_collaborator_limit: undefined,265unlicensed_project_timetravel_limit: undefined,266});267};268269reload = async () => {270await loadCustomizeState();271};272}273274export const store = redux.createStore("customize", CustomizeStore, defaults);275const actions = redux.createActions("customize", CustomizeActions);276// really simple way to have a default value -- gets changed below once the $?.get returns.277actions.setState({ is_commercial: true, ssh_gateway: true });278279// If we are running in the browser, then we customize the schema. This also gets run on the backend280// to generate static content, which can't be customized.281export let commercial: boolean = defaults.is_commercial;282283async function loadCustomizeState() {284if (typeof process != "undefined") {285// running in node.js286return;287}288let customize;289await retry_until_success({290f: async () => {291const url = join(appBasePath, "customize");292try {293customize = await (await fetch(url)).json();294} catch (err) {295const msg = `fetch /customize failed -- retrying - ${err}`;296console.warn(msg);297throw new Error(msg);298}299},300start_delay: 2000,301max_delay: 30000,302});303304const {305configuration,306registration,307strategies,308software = null,309ollama = null, // the derived public information310custom_openai = null,311} = customize;312process_kucalc(configuration);313process_software(software, configuration.is_cocalc_com);314process_customize(configuration); // this sets _is_configured to true315process_ollama(ollama);316process_custom_openai(custom_openai);317const actions = redux.getActions("account");318// Which account creation strategies we support.319actions.setState({ strategies });320// Set whether or not a registration token is required when creating account.321actions.setState({ token: !!registration });322}323324export async function init() {325while (true) {326await loadCustomizeState();327await delay(UPDATE_INTERVAL);328}329}330331function process_ollama(ollama?) {332if (!ollama) return;333actions.setState({ ollama: fromJS(ollama) });334}335336function process_custom_openai(custom_openai?) {337if (!custom_openai) return;338actions.setState({ custom_openai: fromJS(custom_openai) });339}340341function process_kucalc(obj) {342// TODO make this a to_val function in site_settings_conf.kucalc343obj.kucalc = validate_kucalc(obj.kucalc);344obj.is_cocalc_com = obj.kucalc == KUCALC_COCALC_COM;345}346347function process_customize(obj) {348const obj_orig = deep_copy(obj);349for (const k in site_settings_conf) {350const v = site_settings_conf[k];351obj[k] =352obj[k] != null ? obj[k] : (v.to_val?.(v.default, obj_orig) ?? v.default);353}354// the llm markup special case355obj.llm_markup = obj_orig._llm_markup ?? 30;356357// always set time, so other code can know for sure that customize was loaded.358// it also might be helpful to know when359obj["time"] = Date.now();360set_customize(obj);361}362363// "obj" are the already processed values from the database364// this function is also used by hub-landing!365function set_customize(obj) {366// console.log('set_customize obj=\n', JSON.stringify(obj, null, 2));367368// set some special cases, backwards compatibility369commercial = obj.is_commercial = obj.commercial;370371obj._is_configured = true;372actions.setState(obj);373}374375function process_software(software, is_cocalc_com) {376const dbg = (...msg) => console.log("sanitizeSoftwareEnv:", ...msg);377if (software != null) {378// this checks the data coming in from the "/customize" endpoint.379// Next step is to convert it to immutable and store it in the customize store.380software = sanitizeSoftwareEnv({ software, purpose: "webapp" }, dbg);381actions.setState({ software });382} else {383if (is_cocalc_com) {384actions.setState({ software: fromJS(FALLBACK_SOFTWARE_ENV) as any });385} else {386software = sanitizeSoftwareEnv(387{ software: FALLBACK_ONPREM_ENV, purpose: "webapp" },388dbg,389);390actions.setState({ software });391}392}393}394395interface HelpEmailLink {396text?: React.ReactNode;397color?: string;398}399400export const HelpEmailLink: React.FC<HelpEmailLink> = React.memo(401(props: HelpEmailLink) => {402const { text, color } = props;403404const help_email = useTypedRedux("customize", "help_email");405const _is_configured = useTypedRedux("customize", "_is_configured");406407const style: React.CSSProperties = {};408if (color != null) {409style.color = color;410}411412if (_is_configured) {413if (help_email?.length > 0) {414return (415<A href={`mailto:${help_email}`} style={style}>416{text ?? help_email}417</A>418);419} else {420return (421<span>422<em>423{"["}not configured{"]"}424</em>425</span>426);427}428} else {429return <Loading style={{ display: "inline" }} />;430}431},432);433434export const SiteName: React.FC = React.memo(() => {435const site_name = useTypedRedux("customize", "site_name");436437if (site_name != null) {438return <span>{site_name}</span>;439} else {440return <Loading style={{ display: "inline" }} />;441}442});443444interface SiteDescriptionProps {445style?: React.CSSProperties;446site_description?: string;447}448449const SiteDescription0 = rclass<{ style?: React.CSSProperties }>(450class SiteDescription extends React.Component<SiteDescriptionProps> {451public static reduxProps() {452return {453customize: {454site_description: rtypes.string,455},456};457}458459public render(): React.JSX.Element {460const style =461this.props.style != undefined462? this.props.style463: { color: "#666", fontSize: "16px" };464if (this.props.site_description != undefined) {465return <span style={style}>{this.props.site_description}</span>;466} else {467return <Loading style={{ display: "inline" }} />;468}469}470},471);472473// TODO: not used?474export function SiteDescription({ style }: { style?: React.CSSProperties }) {475return (476<Redux>477<SiteDescription0 style={style} />478</Redux>479);480}481482// This generalizes the above in order to pick any selected string value483interface CustomizeStringProps {484name: string;485}486interface CustomizeStringReduxProps {487site_name: string;488site_description: string;489terms_of_service: string;490account_creation_email_instructions: string;491help_email: string;492logo_square: string;493logo_rectangular: string;494splash_image: string;495index_info_html: string;496terms_of_service_url: string;497organization_name: string;498organization_email: string;499organization_url: string;500google_analytics: string;501}502503const CustomizeStringElement = rclass<CustomizeStringProps>(504class CustomizeStringComponent extends React.Component<505CustomizeStringReduxProps & CustomizeStringProps506> {507public static reduxProps = () => {508return {509customize: {510site_name: rtypes.string,511site_description: rtypes.string,512terms_of_service: rtypes.string,513account_creation_email_instructions: rtypes.string,514help_email: rtypes.string,515logo_square: rtypes.string,516logo_rectangular: rtypes.string,517splash_image: rtypes.string,518index_info_html: rtypes.string,519terms_of_service_url: rtypes.string,520organization_name: rtypes.string,521organization_email: rtypes.string,522organization_url: rtypes.string,523google_analytics: rtypes.string,524},525};526};527528shouldComponentUpdate(next) {529if (this.props[this.props.name] == null) return true;530return this.props[this.props.name] != next[this.props.name];531}532533render() {534return <span>{this.props[this.props.name]}</span>;535}536},537);538539// TODO: not used?540export function CustomizeString({ name }: CustomizeStringProps) {541return (542<Redux>543<CustomizeStringElement name={name} />544</Redux>545);546}547548// TODO also make this configurable? Needed in the <Footer/> and maybe elsewhere …549export const CompanyName = function CompanyName() {550return <span>{theme.COMPANY_NAME}</span>;551};552553interface AccountCreationEmailInstructionsProps {554account_creation_email_instructions: string;555}556557const AccountCreationEmailInstructions0 = rclass<{}>(558class AccountCreationEmailInstructions extends React.Component<AccountCreationEmailInstructionsProps> {559public static reduxProps = () => {560return {561customize: {562account_creation_email_instructions: rtypes.string,563},564};565};566567render() {568return (569<h3 style={{ marginTop: 0, textAlign: "center" }}>570{this.props.account_creation_email_instructions}571</h3>572);573}574},575);576577// TODO is this used?578export function AccountCreationEmailInstructions() {579return (580<Redux>581<AccountCreationEmailInstructions0 />582</Redux>583);584}585586export const Footer: React.FC = React.memo(() => {587const intl = useIntl();588const on = useTypedRedux("customize", "organization_name");589const tos = useTypedRedux("customize", "terms_of_service_url");590591const organizationName = on.length > 0 ? on : theme.COMPANY_NAME;592const TOSurl = tos.length > 0 ? tos : PolicyTOSPageUrl;593const webappVersionInfo =594`Version ${smc_version} @ ${build_date}` + ` | ${smc_git_rev.slice(0, 8)}`;595const style: React.CSSProperties = {596textAlign: "center",597paddingBottom: `${UNIT}px`,598};599600const systemStatus = intl.formatMessage({601id: "customize.footer.system-status",602defaultMessage: "System Status",603});604605const name = intl.formatMessage(606{607id: "customize.footer.name",608defaultMessage: "{name} by {organizationName}",609},610{611name: <SiteName />,612organizationName,613},614);615616function contents() {617const elements = [618<A key="name" href={appBasePath}>619{name}620</A>,621<A key="status" href={SystemStatusUrl}>622{systemStatus}623</A>,624<A key="tos" href={TOSurl}>625{intl.formatMessage(labels.terms_of_service)}626</A>,627<HelpEmailLink key="help" />,628<span key="year" title={webappVersionInfo}>629© {YEAR}630</span>,631];632return r_join(elements, <> · </>);633}634635return (636<footer style={style}>637<hr />638<Gap />639{contents()}640</footer>641);642});643644// first step of centralizing these URLs in one place → collecting all such pages into one645// react-class with a 'type' prop is the next step (TODO)646// then consolidate this with the existing site-settings database (e.g. TOS above is one fixed HTML string with an anchor)647648export const PolicyIndexPageUrl = join(appBasePath, "policies");649export const PolicyPricingPageUrl = join(appBasePath, "pricing");650export const PolicyPrivacyPageUrl = join(appBasePath, "policies/privacy");651export const PolicyCopyrightPageUrl = join(appBasePath, "policies/copyright");652export const PolicyTOSPageUrl = join(appBasePath, "policies/terms");653export const SystemStatusUrl = join(appBasePath, "info/status");654export const PAYGODocsUrl = "https://doc.cocalc.com/paygo.html";655656// 1. Google analytics657async function setup_google_analytics(w) {658// init_analytics already makes sure store is configured659const ga4 = store.get("google_analytics");660if (!ga4) return;661662// for commercial setup, enable conversion tracking...663// the gtag initialization664w.dataLayer = w.dataLayer || [];665w.gtag = function () {666w.dataLayer.push(arguments);667};668w.gtag("js", new Date());669w.gtag("config", `"${ga4}"`);670// load tagmanager671const gtag = w.document.createElement("script");672gtag.src = `https://www.googletagmanager.com/gtag/js?id=${ga4}`;673gtag.async = true;674gtag.defer = true;675w.document.getElementsByTagName("head")[0].appendChild(gtag);676}677678// 2. CoCalc analytics679function setup_cocalc_analytics(w) {680// init_analytics already makes sure store is configured681const ctag = w.document.createElement("script");682ctag.src = join(appBasePath, "analytics.js?fqd=false");683ctag.async = true;684ctag.defer = true;685w.document.getElementsByTagName("head")[0].appendChild(ctag);686}687688async function init_analytics() {689await store.until_configured();690if (!store.get("is_commercial")) return;691692let w: any;693try {694w = window;695} catch (_err) {696// Make it so this code can be run on the backend...697return;698}699if (w?.document == null) {700// Double check that this code can be run on the backend (not in a browser).701// see https://github.com/sagemathinc/cocalc-landing/issues/2702return;703}704705await setup_google_analytics(w);706await setup_cocalc_analytics(w);707}708709init_analytics();710711712