import { newid } from "../hashing" import { DEFAULT_TENANT_ID, Configs } from "../constants" import env from "../environment" import { SEPARATOR, DocumentType, UNICODE_MAX, ViewName } from "./constants" import { getTenantId, getGlobalDB } from "../context" import { getGlobalDBName } from "../tenancy/utils" import fetch from "node-fetch" import { doWithDB, allDbs } from "./index" import { getCouchInfo } from "./pouch" import { getAppMetadata } from "../cache/appMetadata" import { checkSlashesInUrl } from "../helpers" import { isDevApp, isDevAppID, getProdAppID } from "./conversions" import { APP_PREFIX } from "./constants" import * as events from "../events" export * from "./constants" export * from "./conversions" export { default as Replication } from "./Replication" /** * Generates a new app ID. * @returns {string} The new app ID which the app doc can be stored under. */ export const generateAppID = (tenantId = null) => { let id = APP_PREFIX if (tenantId) { id += `${tenantId}${SEPARATOR}` } return `${id}${newid()}` } /** * If creating DB allDocs/query params with only a single top level ID this can be used, this * is usually the case as most of our docs are top level e.g. tables, automations, users and so on. * More complex cases such as link docs and rows which have multiple levels of IDs that their * ID consists of need their own functions to build the allDocs parameters. * @param {string} docType The type of document which input params are being built for, e.g. user, * link, app, table and so on. * @param {string|null} docId The ID of the document minus its type - this is only needed if looking * for a singular document. * @param {object} otherProps Add any other properties onto the request, e.g. include_docs. * @returns {object} Parameters which can then be used with an allDocs request. */ export function getDocParams( docType: any, docId: any = null, otherProps: any = {} ) { if (docId == null) { docId = "" } return { ...otherProps, startkey: `${docType}${SEPARATOR}${docId}`, endkey: `${docType}${SEPARATOR}${docId}${UNICODE_MAX}`, } } /** * Retrieve the correct index for a view based on default design DB. */ export function getQueryIndex(viewName: ViewName) { return `database/${viewName}` } /** * Generates a new workspace ID. * @returns {string} The new workspace ID which the workspace doc can be stored under. */ export function generateWorkspaceID() { return `${DocumentType.WORKSPACE}${SEPARATOR}${newid()}` } /** * Gets parameters for retrieving workspaces. */ export function getWorkspaceParams(id = "", otherProps = {}) { return { ...otherProps, startkey: `${DocumentType.WORKSPACE}${SEPARATOR}${id}`, endkey: `${DocumentType.WORKSPACE}${SEPARATOR}${id}${UNICODE_MAX}`, } } /** * Generates a new global user ID. * @returns {string} The new user ID which the user doc can be stored under. */ export function generateGlobalUserID(id?: any) { return `${DocumentType.USER}${SEPARATOR}${id || newid()}` } /** * Gets parameters for retrieving users. */ export function getGlobalUserParams(globalId: any, otherProps: any = {}) { if (!globalId) { globalId = "" } const startkey = otherProps?.startkey return { ...otherProps, // need to include this incase pagination startkey: startkey ? startkey : `${DocumentType.USER}${SEPARATOR}${globalId}`, endkey: `${DocumentType.USER}${SEPARATOR}${globalId}${UNICODE_MAX}`, } } export function getUsersByAppParams(appId: any, otherProps: any = {}) { const prodAppId = getProdAppID(appId) return { ...otherProps, startkey: prodAppId, endkey: `${prodAppId}${UNICODE_MAX}`, } } /** * Generates a template ID. * @param ownerId The owner/user of the template, this could be global or a workspace level. */ export function generateTemplateID(ownerId: any) { return `${DocumentType.TEMPLATE}${SEPARATOR}${ownerId}${SEPARATOR}${newid()}` } export function generateAppUserID(prodAppId: string, userId: string) { return `${prodAppId}${SEPARATOR}${userId}` } /** * Gets parameters for retrieving templates. Owner ID must be specified, either global or a workspace level. */ export function getTemplateParams( ownerId: any, templateId: any, otherProps = {} ) { if (!templateId) { templateId = "" } let final if (templateId) { final = templateId } else { final = `${DocumentType.TEMPLATE}${SEPARATOR}${ownerId}${SEPARATOR}` } return { ...otherProps, startkey: final, endkey: `${final}${UNICODE_MAX}`, } } /** * Generates a new role ID. * @returns {string} The new role ID which the role doc can be stored under. */ export function generateRoleID(id: any) { return `${DocumentType.ROLE}${SEPARATOR}${id || newid()}` } /** * Gets parameters for retrieving a role, this is a utility function for the getDocParams function. */ export function getRoleParams(roleId = null, otherProps = {}) { return getDocParams(DocumentType.ROLE, roleId, otherProps) } export function getStartEndKeyURL(base: any, baseKey: any, tenantId = null) { const tenancy = tenantId ? `${SEPARATOR}${tenantId}` : "" return `${base}?startkey="${baseKey}${tenancy}"&endkey="${baseKey}${tenancy}${UNICODE_MAX}"` } /** * if in production this will use the CouchDB _all_dbs call to retrieve a list of databases. If testing * when using Pouch it will use the pouchdb-all-dbs package. * opts.efficient can be provided to make sure this call is always quick in a multi-tenant environment, * but it may not be 100% accurate in full efficiency mode (some tenantless apps may be missed). */ export async function getAllDbs(opts = { efficient: false }) { const efficient = opts && opts.efficient // specifically for testing we use the pouch package for this if (env.isTest()) { return allDbs() } let dbs: any[] = [] let { url, cookie } = getCouchInfo() async function addDbs(couchUrl: string) { const response = await fetch(checkSlashesInUrl(encodeURI(couchUrl)), { method: "GET", headers: { Authorization: cookie, }, }) if (response.status === 200) { let json = await response.json() dbs = dbs.concat(json) } else { throw "Cannot connect to CouchDB instance" } } let couchUrl = `${url}/_all_dbs` let tenantId = getTenantId() if (!env.MULTI_TENANCY || (!efficient && tenantId === DEFAULT_TENANT_ID)) { // just get all DBs when: // - single tenancy // - default tenant // - apps dbs don't contain tenant id // - non-default tenant dbs are filtered out application side in getAllApps await addDbs(couchUrl) } else { // get prod apps await addDbs(getStartEndKeyURL(couchUrl, DocumentType.APP, tenantId)) // get dev apps await addDbs(getStartEndKeyURL(couchUrl, DocumentType.APP_DEV, tenantId)) // add global db name dbs.push(getGlobalDBName(tenantId)) } return dbs } /** * Lots of different points in the system need to find the full list of apps, this will * enumerate the entire CouchDB cluster and get the list of databases (every app). * * @return {Promise} returns the app information document stored in each app database. */ export async function getAllApps({ dev, all, idsOnly, efficient }: any = {}) { let tenantId = getTenantId() if (!env.MULTI_TENANCY && !tenantId) { tenantId = DEFAULT_TENANT_ID } let dbs = await getAllDbs({ efficient }) const appDbNames = dbs.filter((dbName: any) => { if (env.isTest() && !dbName) { return false } const split = dbName.split(SEPARATOR) // it is an app, check the tenantId if (split[0] === DocumentType.APP) { // tenantId is always right before the UUID const possibleTenantId = split[split.length - 2] const noTenantId = split.length === 2 || possibleTenantId === DocumentType.DEV return ( (tenantId === DEFAULT_TENANT_ID && noTenantId) || possibleTenantId === tenantId ) } return false }) if (idsOnly) { const devAppIds = appDbNames.filter(appId => isDevAppID(appId)) const prodAppIds = appDbNames.filter(appId => !isDevAppID(appId)) switch (dev) { case true: return devAppIds case false: return prodAppIds default: return appDbNames } } const appPromises = appDbNames.map((app: any) => // skip setup otherwise databases could be re-created getAppMetadata(app) ) if (appPromises.length === 0) { return [] } else { const response = await Promise.allSettled(appPromises) const apps = response .filter( (result: any) => result.status === "fulfilled" && result.value != null ) .map(({ value }: any) => value) if (!all) { return apps.filter((app: any) => { if (dev) { return isDevApp(app) } return !isDevApp(app) }) } else { return apps.map((app: any) => ({ ...app, status: isDevApp(app) ? "development" : "published", })) } } } /** * Utility function for getAllApps but filters to production apps only. */ export async function getProdAppIDs() { return (await getAllApps({ idsOnly: true })).filter( (id: any) => !isDevAppID(id) ) } /** * Utility function for the inverse of above. */ export async function getDevAppIDs() { return (await getAllApps({ idsOnly: true })).filter((id: any) => isDevAppID(id) ) } export async function dbExists(dbName: any) { let exists = false return doWithDB( dbName, async (db: any) => { try { // check if database exists const info = await db.info() if (info && !info.error) { exists = true } } catch (err) { exists = false } return exists }, { skip_setup: true } ) } /** * Generates a new configuration ID. * @returns {string} The new configuration ID which the config doc can be stored under. */ export const generateConfigID = ({ type, workspace, user }: any) => { const scope = [type, workspace, user].filter(Boolean).join(SEPARATOR) return `${DocumentType.CONFIG}${SEPARATOR}${scope}` } /** * Gets parameters for retrieving configurations. */ export const getConfigParams = ( { type, workspace, user }: any, otherProps = {} ) => { const scope = [type, workspace, user].filter(Boolean).join(SEPARATOR) return { ...otherProps, startkey: `${DocumentType.CONFIG}${SEPARATOR}${scope}`, endkey: `${DocumentType.CONFIG}${SEPARATOR}${scope}${UNICODE_MAX}`, } } /** * Generates a new dev info document ID - this is scoped to a user. * @returns {string} The new dev info ID which info for dev (like api key) can be stored under. */ export const generateDevInfoID = (userId: any) => { return `${DocumentType.DEV_INFO}${SEPARATOR}${userId}` } /** * Returns the most granular configuration document from the DB based on the type, workspace and userID passed. * @param {Object} db - db instance to query * @param {Object} scopes - the type, workspace and userID scopes of the configuration. * @returns The most granular configuration document based on the scope. */ export const getScopedFullConfig = async function ( db: any, { type, user, workspace }: any ) { const response = await db.allDocs( getConfigParams( { type, user, workspace }, { include_docs: true, } ) ) function determineScore(row: any) { const config = row.doc // Config is specific to a user and a workspace if (config._id.includes(generateConfigID({ type, user, workspace }))) { return 4 } else if (config._id.includes(generateConfigID({ type, user }))) { // Config is specific to a user only return 3 } else if (config._id.includes(generateConfigID({ type, workspace }))) { // Config is specific to a workspace only return 2 } else if (config._id.includes(generateConfigID({ type }))) { // Config is specific to a type only return 1 } return 0 } // Find the config with the most granular scope based on context let scopedConfig = response.rows.sort( (a: any, b: any) => determineScore(a) - determineScore(b) )[0] // custom logic for settings doc if (type === Configs.SETTINGS) { if (scopedConfig && scopedConfig.doc) { // overrides affected by environment variables scopedConfig.doc.config.platformUrl = await getPlatformUrl({ tenantAware: true, }) scopedConfig.doc.config.analyticsEnabled = await events.analytics.enabled() } else { // defaults scopedConfig = { doc: { _id: generateConfigID({ type, user, workspace }), config: { platformUrl: await getPlatformUrl({ tenantAware: true }), analyticsEnabled: await events.analytics.enabled(), }, }, } } } return scopedConfig && scopedConfig.doc } export const getPlatformUrl = async (opts = { tenantAware: true }) => { let platformUrl = env.PLATFORM_URL || "http://localhost:10000" if (!env.SELF_HOSTED && env.MULTI_TENANCY && opts.tenantAware) { // cloud and multi tenant - add the tenant to the default platform url const tenantId = getTenantId() if (!platformUrl.includes("localhost:")) { platformUrl = platformUrl.replace("://", `://${tenantId}.`) } } else if (env.SELF_HOSTED) { const db = getGlobalDB() // get the doc directly instead of with getScopedConfig to prevent loop let settings try { settings = await db.get(generateConfigID({ type: Configs.SETTINGS })) } catch (e: any) { if (e.status !== 404) { throw e } } // self hosted - check for platform url override if (settings && settings.config && settings.config.platformUrl) { platformUrl = settings.config.platformUrl } } return platformUrl } export function pagination( data: any[], pageSize: number, { paginate, property, getKey, }: { paginate: boolean property: string getKey?: (doc: any) => string | undefined } = { paginate: true, property: "_id", } ) { if (!paginate) { return { data, hasNextPage: false } } const hasNextPage = data.length > pageSize let nextPage = undefined if (!getKey) { getKey = (doc: any) => (property ? doc?.[property] : doc?._id) } if (hasNextPage) { nextPage = getKey(data[pageSize]) } return { data: data.slice(0, pageSize), hasNextPage, nextPage, } } export async function getScopedConfig(db: any, params: any) { const configDoc = await getScopedFullConfig(db, params) return configDoc && configDoc.config ? configDoc.config : configDoc }