/* * This file is part of CoCalc: Copyright © 2020 Sagemath, Inc. * License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details */ // This unifies the entire webapp configuration – endpoint /customize // The main goal is to optimize this, to use as little DB interactions // as necessary, use caching, etc. // This manages the webapp's configuration based on the hostname // (allows whitelabeling). import type { PostgreSQL } from "@cocalc/database/postgres/types"; import { getSoftwareEnvironments } from "@cocalc/server/software-envs"; import { callback2 as cb2 } from "@cocalc/util/async-utils"; import { EXTRAS as SERVER_SETTINGS_EXTRAS } from "@cocalc/util/db-schema/site-settings-extras"; import { SoftwareEnvConfig } from "@cocalc/util/sanitize-software-envs"; import { site_settings_conf as SITE_SETTINGS_CONF } from "@cocalc/util/schema"; import { delay } from "awaiting"; import debug from "debug"; import { parseDomain, ParseResultType } from "parse-domain"; import { get_passport_manager, PassportManager } from "./auth"; import getServerSettings from "./servers/server-settings"; import { have_active_registration_tokens } from "./utils"; const L = debug("hub:webapp-config"); import LRUCache from "lru-cache"; const CACHE = new LRUCache({ max: 1000, maxAge: 60 * 1000 }); // 1 minutes export function clear_cache(): void { CACHE.reset(); } type Theme = { [key: string]: string | boolean }; interface Config { // todo configuration: any; registration: any; strategies: object; software: SoftwareEnvConfig | null; } async function get_passport_manager_async(): Promise { // the only issue here is, that the http server already starts up before the // passport manager is configured – but, the passport manager depends on the http server // we just retry during that initial period of uncertainty… let ms = 100; while (true) { const pp_manager = get_passport_manager(); if (pp_manager != null) { return pp_manager; } else { L( `WARNING: Passport Manager not available yet -- trying again in ${ms}ms` ); await delay(ms); ms = Math.min(10000, 1.3 * ms); } } } export class WebappConfiguration { private readonly db: PostgreSQL; private data?: any; private passport_manager: PassportManager; constructor({ db }) { this.db = db; this.init(); } private async init(): Promise { // this.data.pub updates automatically – do not modify it! this.data = await getServerSettings(); this.passport_manager = await get_passport_manager_async(); } // server settings with whitelabeling settings // TODO post-process all values public async settings(vID: string) { const res = await cb2(this.db._query, { query: "SELECT id, settings FROM whitelabeling", cache: true, where: { "id = $::TEXT": vID }, }); if (this.data == null) { // settings not yet initialized return {}; } const data = res.rows[0]; if (data != null) { return { ...this.data.all, ...data.settings }; } else { return this.data.all; } } // derive the vanity ID from the host string private get_vanity_id(host: string): string | undefined { const host_parsed = parseDomain(host); if (host_parsed.type === ParseResultType.Listed) { // vanity for vanity.cocalc.com or foo.p for foo.p.cocalc.com return host_parsed.subDomains.join("."); } return undefined; } private async theme(vID: string): Promise { const res = await cb2(this.db._query, { query: "SELECT id, theme FROM whitelabeling", cache: true, where: { "id = $::TEXT": vID }, }); const data = res.rows[0]; if (data != null) { // post-process data, but do not set default values… const theme: Theme = {}; for (const [key, value] of Object.entries(data.theme)) { const config = SITE_SETTINGS_CONF[key] ?? SERVER_SETTINGS_EXTRAS[key]; if (typeof config?.to_val == "function") { theme[key] = config.to_val(value, data.theme); } else { if (typeof value == "string" || typeof value == "boolean") { theme[key] = value; } } } L(`vanity theme=${JSON.stringify(theme)}`); return theme; } else { L(`theme id=${vID} not found`); return {}; } } private async get_vanity(vID): Promise { if (vID != null && vID !== "") { L(`vanity ID = "${vID}"`); return await this.theme(vID); } else { return {}; } } // returns the global configuration + eventually vanity specific site config settings private async get_configuration({ host, country }) { if (this.data == null) { // settings not yet initialized return {}; } const vID = this.get_vanity_id(host); const config = this.data.pub; const vanity = this.get_vanity(vID); return { ...config, ...vanity, ...{ country, dns: host } }; } private get_strategies(): object { const key = "strategies"; let strategies = CACHE.get(key); if (strategies == null) { strategies = this.passport_manager.get_strategies_v2(); CACHE.set(key, strategies); } return strategies as object; } private async get_config({ country, host }): Promise { const [configuration, registration, software] = await Promise.all([ this.get_configuration({ host, country }), have_active_registration_tokens(this.db), getSoftwareEnvironments("webapp"), ]); const strategies = this.get_strategies(); return { configuration, registration, strategies, software }; } // it returns a shallow copy, hence you can modify/add keys in the returned map! public async get({ country, host }): Promise { const key = `config::${country}::${host}`; let config = CACHE.get(key); if (config == null) { config = await this.get_config({ country, host }); CACHE.set(key, config); } else { L(`cache hit -- '${key}'`); } return config as Config; } }