Path: blob/master/src/packages/frontend/customize.tsx
1496 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.78import { fromJS, List, Map } from "immutable";9import { join } from "path";10import { useIntl } from "react-intl";1112import {13Actions,14rclass,15React,16redux,17Redux,18rtypes,19Store,20TypedMap,21useTypedRedux,22} from "@cocalc/frontend/app-framework";23import {24A,25build_date,26Gap,27Loading,28r_join,29smc_git_rev,30smc_version,31UNIT,32} from "@cocalc/frontend/components";33import { getGoogleCloudImages, getImages } from "@cocalc/frontend/compute/api";34import { appBasePath } from "@cocalc/frontend/customize/app-base-path";35import { labels, Locale } from "@cocalc/frontend/i18n";36import { callback2, retry_until_success } from "@cocalc/util/async-utils";37import {38ComputeImage,39FALLBACK_ONPREM_ENV,40FALLBACK_SOFTWARE_ENV,41} from "@cocalc/util/compute-images";42import { DEFAULT_COMPUTE_IMAGE } from "@cocalc/util/db-schema";43import type {44GoogleCloudImages,45Images,46} from "@cocalc/util/db-schema/compute-servers";47import { LLMServicesAvailable } from "@cocalc/util/db-schema/llm-utils";48import {49Config,50KUCALC_COCALC_COM,51KUCALC_DISABLED,52KUCALC_ON_PREMISES,53site_settings_conf,54} from "@cocalc/util/db-schema/site-defaults";55import { deep_copy, dict, YEAR } from "@cocalc/util/misc";56import { reuseInFlight } from "@cocalc/util/reuse-in-flight";57import { sanitizeSoftwareEnv } from "@cocalc/util/sanitize-software-envs";58import * as theme from "@cocalc/util/theme";59import { CustomLLMPublic } from "@cocalc/util/types/llm";60import { DefaultQuotaSetting, Upgrades } from "@cocalc/util/upgrades/quota";61export { TermsOfService } from "@cocalc/frontend/customize/terms-of-service";62import { delay } from "awaiting";6364// update every 2 minutes.65const UPDATE_INTERVAL = 2 * 60000;6667// this sets UI modes for using a kubernetes based back-end68// 'yes' (historic value) equals 'cocalc.com'69function validate_kucalc(k?): string {70if (k == null) return KUCALC_DISABLED;71const val = k.trim().toLowerCase();72if ([KUCALC_DISABLED, KUCALC_COCALC_COM, KUCALC_ON_PREMISES].includes(val)) {73return val;74}75console.warn(`site settings customize: invalid kucalc value ${k}`);76return KUCALC_DISABLED;77}7879// populate all default key/values in the "customize" store80const defaultKeyVals: [string, string | string[]][] = [];81for (const k in site_settings_conf) {82const v: Config = site_settings_conf[k];83const value: any =84typeof v.to_val === "function" ? v.to_val(v.default) : v.default;85defaultKeyVals.push([k, value]);86}87const defaults: any = dict(defaultKeyVals);88defaults.is_commercial = defaults.commercial;89defaults._is_configured = false; // will be true after set via call to server9091// CustomizeState is maybe extension of what's in SiteSettings92// so maybe there is a more clever way like this to do it than93// what I did below.94// type SiteSettings = { [k in keyof SiteSettingsConfig]: any };9596export type SoftwareEnvironments = TypedMap<{97groups: List<string>;98default: string;99environments: Map<string, TypedMap<ComputeImage>>;100}>;101102export interface CustomizeState {103time: number; // this will always get set once customize has loaded.104is_commercial: boolean;105openai_enabled: boolean;106google_vertexai_enabled: boolean;107mistral_enabled: boolean;108anthropic_enabled: boolean;109ollama_enabled: boolean;110custom_openai_enabled: boolean;111neural_search_enabled: boolean;112datastore: boolean;113ssh_gateway: boolean;114ssh_gateway_dns: string; // e.g. "ssh.cocalc.com"115ssh_gateway_fingerprint: string; // e.g. "SHA256:a8284..."116account_creation_email_instructions: string;117commercial: boolean;118default_quotas: TypedMap<DefaultQuotaSetting>;119dns: string; // e.g. "cocalc.com"120email_enabled: false;121email_signup: boolean;122anonymous_signup: boolean;123google_analytics: string;124help_email: string;125iframe_comm_hosts: string[];126index_info_html: string;127is_cocalc_com: boolean;128is_personal: boolean;129kucalc: string;130logo_rectangular: string;131logo_square: string;132max_upgrades: TypedMap<Partial<Upgrades>>;133134// Commercialization parameters.135// Be sure to also update disableCommercializationParameters136// below if you change these:137nonfree_countries?: List<string>;138limit_free_project_uptime: number; // minutes139require_license_to_create_project?: boolean;140unlicensed_project_collaborator_limit?: number;141unlicensed_project_timetravel_limit?: number;142143onprem_quota_heading: string;144organization_email: string;145organization_name: string;146organization_url: string;147share_server: boolean;148site_description: string;149site_name: string;150splash_image: string;151terms_of_service: string;152terms_of_service_url: string;153theming: boolean;154verify_emails: false;155version_min_browser: number;156version_min_project: number;157version_recommended_browser: number;158versions: string;159// extra setting, injected by the hub, not the DB160// we expect this to follow "ISO 3166-1 Alpha 2" + K1 (Tor network) + XX (unknown)161// use a lib like https://github.com/michaelwittig/node-i18n-iso-countries162country: string;163// flag to signal data stored in the Store.164software: SoftwareEnvironments;165_is_configured: boolean;166jupyter_api_enabled?: boolean;167168compute_servers_enabled?: boolean;169["compute_servers_google-cloud_enabled"]?: boolean;170compute_servers_lambda_enabled?: boolean;171compute_servers_dns_enabled?: boolean;172compute_servers_dns?: string;173compute_servers_images?: TypedMap<Images> | string | null;174compute_servers_images_google?: TypedMap<GoogleCloudImages> | string | null;175176llm_markup: number;177178ollama?: TypedMap<{ [key: string]: TypedMap<CustomLLMPublic> }>;179custom_openai?: TypedMap<{ [key: string]: TypedMap<CustomLLMPublic> }>;180selectable_llms: List<string>;181default_llm?: string;182user_defined_llm: boolean;183llm_default_quota?: number;184185insecure_test_mode?: boolean;186187i18n?: List<Locale>;188189user_tracking?: string;190}191192export class CustomizeStore extends Store<CustomizeState> {193async until_configured(): Promise<void> {194if (this.get("_is_configured")) return;195await callback2(this.wait, { until: () => this.get("_is_configured") });196}197198get_iframe_comm_hosts(): string[] {199const hosts = this.get("iframe_comm_hosts");200if (hosts == null) return [];201return hosts.toJS();202}203204async getDefaultComputeImage(): Promise<string> {205await this.until_configured();206return this.getIn(["software", "default"]) ?? DEFAULT_COMPUTE_IMAGE;207}208209getEnabledLLMs(): LLMServicesAvailable {210return {211openai: this.get("openai_enabled"),212google: this.get("google_vertexai_enabled"),213ollama: this.get("ollama_enabled"),214custom_openai: this.get("custom_openai_enabled"),215mistralai: this.get("mistral_enabled"),216anthropic: this.get("anthropic_enabled"),217user: this.get("user_defined_llm"),218};219}220}221222export class CustomizeActions extends Actions<CustomizeState> {223// reload is admin only224updateComputeServerImages = reuseInFlight(async (reload?) => {225if (!store.get("compute_servers_enabled")) {226this.setState({ compute_servers_images: fromJS({}) as any });227return;228}229try {230this.setState({231compute_servers_images: fromJS(await getImages(reload)) as any,232});233} catch (err) {234this.setState({ compute_servers_images: `${err}` });235}236});237238updateComputeServerImagesGoogle = reuseInFlight(async (reload?) => {239if (!store.get("compute_servers_google-cloud_enabled")) {240this.setState({ compute_servers_images_google: fromJS({}) as any });241return;242}243try {244this.setState({245compute_servers_images_google: fromJS(246await getGoogleCloudImages(reload),247) as any,248});249} catch (err) {250this.setState({ compute_servers_images_google: `${err}` });251}252});253254// this is used for accounts that have legacy upgrades255disableCommercializationParameters = () => {256this.setState({257limit_free_project_uptime: undefined,258require_license_to_create_project: undefined,259unlicensed_project_collaborator_limit: undefined,260unlicensed_project_timetravel_limit: undefined,261});262};263264reload = async () => {265await loadCustomizeState();266};267}268269export const store = redux.createStore("customize", CustomizeStore, defaults);270const actions = redux.createActions("customize", CustomizeActions);271// really simple way to have a default value -- gets changed below once the $?.get returns.272actions.setState({ is_commercial: true, ssh_gateway: true });273274// If we are running in the browser, then we customize the schema. This also gets run on the backend275// to generate static content, which can't be customized.276export let commercial: boolean = defaults.is_commercial;277278async function loadCustomizeState() {279if (typeof process != "undefined") {280// running in node.js281return;282}283let customize;284await retry_until_success({285f: async () => {286const url = join(appBasePath, "customize");287try {288customize = await (await fetch(url)).json();289} catch (err) {290const msg = `fetch /customize failed -- retrying - ${err}`;291console.warn(msg);292throw new Error(msg);293}294},295start_delay: 2000,296max_delay: 30000,297});298299const {300configuration,301registration,302strategies,303software = null,304ollama = null, // the derived public information305custom_openai = null,306} = customize;307process_kucalc(configuration);308process_software(software, configuration.is_cocalc_com);309process_customize(configuration); // this sets _is_configured to true310process_ollama(ollama);311process_custom_openai(custom_openai);312const actions = redux.getActions("account");313// Which account creation strategies we support.314actions.setState({ strategies });315// Set whether or not a registration token is required when creating account.316actions.setState({ token: !!registration });317}318319export async function init() {320while (true) {321await loadCustomizeState();322await delay(UPDATE_INTERVAL);323}324}325326function process_ollama(ollama?) {327if (!ollama) return;328actions.setState({ ollama: fromJS(ollama) });329}330331function process_custom_openai(custom_openai?) {332if (!custom_openai) return;333actions.setState({ custom_openai: fromJS(custom_openai) });334}335336function process_kucalc(obj) {337// TODO make this a to_val function in site_settings_conf.kucalc338obj.kucalc = validate_kucalc(obj.kucalc);339obj.is_cocalc_com = obj.kucalc == KUCALC_COCALC_COM;340}341342function process_customize(obj) {343const obj_orig = deep_copy(obj);344for (const k in site_settings_conf) {345const v = site_settings_conf[k];346obj[k] =347obj[k] != null ? obj[k] : (v.to_val?.(v.default, obj_orig) ?? v.default);348}349// the llm markup special case350obj.llm_markup = obj_orig._llm_markup ?? 30;351352// always set time, so other code can know for sure that customize was loaded.353// it also might be helpful to know when354obj["time"] = Date.now();355set_customize(obj);356}357358// "obj" are the already processed values from the database359// this function is also used by hub-landing!360function set_customize(obj) {361// console.log('set_customize obj=\n', JSON.stringify(obj, null, 2));362363// set some special cases, backwards compatibility364commercial = obj.is_commercial = obj.commercial;365366obj._is_configured = true;367actions.setState(obj);368}369370function process_software(software, is_cocalc_com) {371const dbg = (...msg) => console.log("sanitizeSoftwareEnv:", ...msg);372if (software != null) {373// this checks the data coming in from the "/customize" endpoint.374// Next step is to convert it to immutable and store it in the customize store.375software = sanitizeSoftwareEnv({ software, purpose: "webapp" }, dbg);376actions.setState({ software });377} else {378if (is_cocalc_com) {379actions.setState({ software: fromJS(FALLBACK_SOFTWARE_ENV) as any });380} else {381software = sanitizeSoftwareEnv(382{ software: FALLBACK_ONPREM_ENV, purpose: "webapp" },383dbg,384);385actions.setState({ software });386}387}388}389390interface HelpEmailLink {391text?: React.ReactNode;392color?: string;393}394395export const HelpEmailLink: React.FC<HelpEmailLink> = React.memo(396(props: HelpEmailLink) => {397const { text, color } = props;398399const help_email = useTypedRedux("customize", "help_email");400const _is_configured = useTypedRedux("customize", "_is_configured");401402const style: React.CSSProperties = {};403if (color != null) {404style.color = color;405}406407if (_is_configured) {408if (help_email?.length > 0) {409return (410<A href={`mailto:${help_email}`} style={style}>411{text ?? help_email}412</A>413);414} else {415return (416<span>417<em>418{"["}not configured{"]"}419</em>420</span>421);422}423} else {424return <Loading style={{ display: "inline" }} />;425}426},427);428429export const SiteName: React.FC = React.memo(() => {430const site_name = useTypedRedux("customize", "site_name");431432if (site_name != null) {433return <span>{site_name}</span>;434} else {435return <Loading style={{ display: "inline" }} />;436}437});438439interface SiteDescriptionProps {440style?: React.CSSProperties;441site_description?: string;442}443444const SiteDescription0 = rclass<{ style?: React.CSSProperties }>(445class SiteDescription extends React.Component<SiteDescriptionProps> {446public static reduxProps() {447return {448customize: {449site_description: rtypes.string,450},451};452}453454public render(): React.JSX.Element {455const style =456this.props.style != undefined457? this.props.style458: { color: "#666", fontSize: "16px" };459if (this.props.site_description != undefined) {460return <span style={style}>{this.props.site_description}</span>;461} else {462return <Loading style={{ display: "inline" }} />;463}464}465},466);467468// TODO: not used?469export function SiteDescription({ style }: { style?: React.CSSProperties }) {470return (471<Redux>472<SiteDescription0 style={style} />473</Redux>474);475}476477// This generalizes the above in order to pick any selected string value478interface CustomizeStringProps {479name: string;480}481interface CustomizeStringReduxProps {482site_name: string;483site_description: string;484terms_of_service: string;485account_creation_email_instructions: string;486help_email: string;487logo_square: string;488logo_rectangular: string;489splash_image: string;490index_info_html: string;491terms_of_service_url: string;492organization_name: string;493organization_email: string;494organization_url: string;495google_analytics: string;496}497498const CustomizeStringElement = rclass<CustomizeStringProps>(499class CustomizeStringComponent extends React.Component<500CustomizeStringReduxProps & CustomizeStringProps501> {502public static reduxProps = () => {503return {504customize: {505site_name: rtypes.string,506site_description: rtypes.string,507terms_of_service: rtypes.string,508account_creation_email_instructions: rtypes.string,509help_email: rtypes.string,510logo_square: rtypes.string,511logo_rectangular: rtypes.string,512splash_image: rtypes.string,513index_info_html: rtypes.string,514terms_of_service_url: rtypes.string,515organization_name: rtypes.string,516organization_email: rtypes.string,517organization_url: rtypes.string,518google_analytics: rtypes.string,519},520};521};522523shouldComponentUpdate(next) {524if (this.props[this.props.name] == null) return true;525return this.props[this.props.name] != next[this.props.name];526}527528render() {529return <span>{this.props[this.props.name]}</span>;530}531},532);533534// TODO: not used?535export function CustomizeString({ name }: CustomizeStringProps) {536return (537<Redux>538<CustomizeStringElement name={name} />539</Redux>540);541}542543// TODO also make this configurable? Needed in the <Footer/> and maybe elsewhere …544export const CompanyName = function CompanyName() {545return <span>{theme.COMPANY_NAME}</span>;546};547548interface AccountCreationEmailInstructionsProps {549account_creation_email_instructions: string;550}551552const AccountCreationEmailInstructions0 = rclass<{}>(553class AccountCreationEmailInstructions extends React.Component<AccountCreationEmailInstructionsProps> {554public static reduxProps = () => {555return {556customize: {557account_creation_email_instructions: rtypes.string,558},559};560};561562render() {563return (564<h3 style={{ marginTop: 0, textAlign: "center" }}>565{this.props.account_creation_email_instructions}566</h3>567);568}569},570);571572// TODO is this used?573export function AccountCreationEmailInstructions() {574return (575<Redux>576<AccountCreationEmailInstructions0 />577</Redux>578);579}580581export const Footer: React.FC = React.memo(() => {582const intl = useIntl();583const on = useTypedRedux("customize", "organization_name");584const tos = useTypedRedux("customize", "terms_of_service_url");585586const organizationName = on.length > 0 ? on : theme.COMPANY_NAME;587const TOSurl = tos.length > 0 ? tos : PolicyTOSPageUrl;588const webappVersionInfo =589`Version ${smc_version} @ ${build_date}` + ` | ${smc_git_rev.slice(0, 8)}`;590const style: React.CSSProperties = {591color: "gray",592textAlign: "center",593paddingBottom: `${UNIT}px`,594};595596const systemStatus = intl.formatMessage({597id: "customize.footer.system-status",598defaultMessage: "System Status",599});600601const name = intl.formatMessage(602{603id: "customize.footer.name",604defaultMessage: "{name} by {organizationName}",605},606{607name: <SiteName />,608organizationName,609},610);611612function contents() {613const elements = [614<A key="name" href={appBasePath}>615{name}616</A>,617<A key="status" href={SystemStatusUrl}>618{systemStatus}619</A>,620<A key="tos" href={TOSurl}>621{intl.formatMessage(labels.terms_of_service)}622</A>,623<HelpEmailLink key="help" />,624<span key="year" title={webappVersionInfo}>625© {YEAR}626</span>,627];628return r_join(elements, <> · </>);629}630631return (632<footer style={style}>633<hr />634<Gap />635{contents()}636</footer>637);638});639640// first step of centralizing these URLs in one place → collecting all such pages into one641// react-class with a 'type' prop is the next step (TODO)642// then consolidate this with the existing site-settings database (e.g. TOS above is one fixed HTML string with an anchor)643644export const PolicyIndexPageUrl = join(appBasePath, "policies");645export const PolicyPricingPageUrl = join(appBasePath, "pricing");646export const PolicyPrivacyPageUrl = join(appBasePath, "policies/privacy");647export const PolicyCopyrightPageUrl = join(appBasePath, "policies/copyright");648export const PolicyTOSPageUrl = join(appBasePath, "policies/terms");649export const SystemStatusUrl = join(appBasePath, "info/status");650export const PAYGODocsUrl = "https://doc.cocalc.com/paygo.html";651652// 1. Google analytics653async function setup_google_analytics(w) {654// init_analytics already makes sure store is configured655const ga4 = store.get("google_analytics");656if (!ga4) return;657658// for commercial setup, enable conversion tracking...659// the gtag initialization660w.dataLayer = w.dataLayer || [];661w.gtag = function () {662w.dataLayer.push(arguments);663};664w.gtag("js", new Date());665w.gtag("config", `"${ga4}"`);666// load tagmanager667const gtag = w.document.createElement("script");668gtag.src = `https://www.googletagmanager.com/gtag/js?id=${ga4}`;669gtag.async = true;670gtag.defer = true;671w.document.getElementsByTagName("head")[0].appendChild(gtag);672}673674// 2. CoCalc analytics675function setup_cocalc_analytics(w) {676// init_analytics already makes sure store is configured677const ctag = w.document.createElement("script");678ctag.src = join(appBasePath, "analytics.js?fqd=false");679ctag.async = true;680ctag.defer = true;681w.document.getElementsByTagName("head")[0].appendChild(ctag);682}683684async function init_analytics() {685await store.until_configured();686if (!store.get("is_commercial")) return;687688let w: any;689try {690w = window;691} catch (_err) {692// Make it so this code can be run on the backend...693return;694}695if (w?.document == null) {696// Double check that this code can be run on the backend (not in a browser).697// see https://github.com/sagemathinc/cocalc-landing/issues/2698return;699}700701await setup_google_analytics(w);702await setup_cocalc_analytics(w);703}704705init_analytics();706707708