budibase/packages/backend-core/src/db/utils.js

454 lines
13 KiB
JavaScript
Raw Normal View History

2021-04-19 12:34:07 +02:00
const { newid } = require("../hashing")
2021-05-13 12:06:08 +02:00
const Replication = require("./Replication")
const { DEFAULT_TENANT_ID, Configs } = require("../constants")
const env = require("../environment")
const {
StaticDatabases,
SEPARATOR,
DocumentTypes,
APP_PREFIX,
APP_DEV,
} = require("./constants")
const {
getTenantId,
getTenantIDFromAppID,
getGlobalDBName,
} = require("../tenancy")
const fetch = require("node-fetch")
const { getCouch } = require("./index")
const { getAppMetadata } = require("../cache/appMetadata")
const { checkSlashesInUrl } = require("../helpers")
const {
isDevApp,
isProdAppID,
isDevAppID,
getDevelopmentAppID,
getProdAppID,
} = require("./conversions")
const UNICODE_MAX = "\ufff0"
exports.ViewNames = {
USER_BY_EMAIL: "by_email",
}
exports.StaticDatabases = StaticDatabases
exports.DocumentTypes = DocumentTypes
exports.APP_PREFIX = APP_PREFIX
exports.APP_DEV = exports.APP_DEV_PREFIX = APP_DEV
exports.SEPARATOR = SEPARATOR
exports.getTenantIDFromAppID = getTenantIDFromAppID
exports.isDevApp = isDevApp
exports.isProdAppID = isProdAppID
exports.isDevAppID = isDevAppID
exports.getDevelopmentAppID = getDevelopmentAppID
exports.getProdAppID = getProdAppID
/**
* 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.
*/
function getDocParams(docType, docId = null, otherProps = {}) {
if (docId == null) {
docId = ""
}
return {
...otherProps,
startkey: `${docType}${SEPARATOR}${docId}`,
endkey: `${docType}${SEPARATOR}${docId}${UNICODE_MAX}`,
}
}
2021-04-19 12:34:07 +02:00
/**
* Generates a new workspace ID.
* @returns {string} The new workspace ID which the workspace doc can be stored under.
2021-04-19 12:34:07 +02:00
*/
exports.generateWorkspaceID = () => {
return `${DocumentTypes.WORKSPACE}${SEPARATOR}${newid()}`
2021-04-19 12:34:07 +02:00
}
2021-04-07 12:33:16 +02:00
/**
* Gets parameters for retrieving workspaces.
2021-04-19 17:16:46 +02:00
*/
exports.getWorkspaceParams = (id = "", otherProps = {}) => {
2021-04-19 17:16:46 +02:00
return {
...otherProps,
startkey: `${DocumentTypes.WORKSPACE}${SEPARATOR}${id}`,
endkey: `${DocumentTypes.WORKSPACE}${SEPARATOR}${id}${UNICODE_MAX}`,
2021-04-19 17:16:46 +02:00
}
}
/**
* Generates a new global user ID.
* @returns {string} The new user ID which the user doc can be stored under.
*/
2021-05-04 12:32:22 +02:00
exports.generateGlobalUserID = id => {
2021-04-21 22:08:04 +02:00
return `${DocumentTypes.USER}${SEPARATOR}${id || newid()}`
}
2021-04-19 17:16:46 +02:00
/**
* Gets parameters for retrieving users.
2021-04-07 12:33:16 +02:00
*/
exports.getGlobalUserParams = (globalId, otherProps = {}) => {
if (!globalId) {
globalId = ""
}
2021-04-07 12:33:16 +02:00
return {
...otherProps,
startkey: `${DocumentTypes.USER}${SEPARATOR}${globalId}`,
endkey: `${DocumentTypes.USER}${SEPARATOR}${globalId}${UNICODE_MAX}`,
2021-04-07 12:33:16 +02:00
}
}
/**
* Generates a template ID.
* @param ownerId The owner/user of the template, this could be global or a workspace level.
*/
2021-05-04 12:32:22 +02:00
exports.generateTemplateID = ownerId => {
return `${DocumentTypes.TEMPLATE}${SEPARATOR}${ownerId}${SEPARATOR}${newid()}`
}
/**
* Gets parameters for retrieving templates. Owner ID must be specified, either global or a workspace level.
*/
exports.getTemplateParams = (ownerId, templateId, otherProps = {}) => {
if (!templateId) {
templateId = ""
}
2021-04-21 19:15:57 +02:00
let final
if (templateId) {
final = templateId
} else {
final = `${DocumentTypes.TEMPLATE}${SEPARATOR}${ownerId}${SEPARATOR}`
}
2021-04-07 12:33:16 +02:00
return {
...otherProps,
startkey: final,
endkey: `${final}${UNICODE_MAX}`,
2021-04-07 12:33:16 +02:00
}
}
2021-04-20 19:14:36 +02:00
/**
* Generates a new role ID.
* @returns {string} The new role ID which the role doc can be stored under.
*/
exports.generateRoleID = id => {
return `${DocumentTypes.ROLE}${SEPARATOR}${id || newid()}`
}
/**
* Gets parameters for retrieving a role, this is a utility function for the getDocParams function.
*/
exports.getRoleParams = (roleId = null, otherProps = {}) => {
return getDocParams(DocumentTypes.ROLE, roleId, otherProps)
}
2021-08-17 15:11:03 +02:00
exports.getCouchUrl = () => {
2021-08-18 12:55:19 +02:00
if (!env.COUCH_DB_URL) return
2021-08-17 15:11:03 +02:00
// username and password already exist in URL
if (env.COUCH_DB_URL.includes("@")) {
return env.COUCH_DB_URL
}
const [protocol, ...rest] = env.COUCH_DB_URL.split("://")
2021-08-18 12:23:28 +02:00
if (!env.COUCH_DB_USERNAME || !env.COUCH_DB_PASSWORD) {
throw new Error(
"CouchDB configuration invalid. You must provide a fully qualified CouchDB url, or the COUCH_DB_USER and COUCH_DB_PASSWORD environment variables."
)
}
2021-08-17 15:11:03 +02:00
return `${protocol}://${env.COUCH_DB_USERNAME}:${env.COUCH_DB_PASSWORD}@${rest}`
}
exports.getStartEndKeyURL = (base, baseKey, 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.
*/
exports.getAllDbs = async () => {
// specifically for testing we use the pouch package for this
if (env.isTest()) {
return getCouch().allDbs()
}
let dbs = []
async function addDbs(url) {
const response = await fetch(checkSlashesInUrl(encodeURI(url)))
if (response.status === 200) {
let json = await response.json()
dbs = dbs.concat(json)
} else {
throw "Cannot connect to CouchDB instance"
}
}
let couchUrl = `${exports.getCouchUrl()}/_all_dbs`
let tenantId = getTenantId()
if (!env.MULTI_TENANCY || 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(
exports.getStartEndKeyURL(couchUrl, DocumentTypes.APP, tenantId)
)
// get dev apps
await addDbs(
exports.getStartEndKeyURL(couchUrl, DocumentTypes.APP_DEV, tenantId)
)
// add global db name
dbs.push(getGlobalDBName(tenantId))
}
return dbs
}
2021-05-14 17:31:07 +02:00
/**
* 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).
2022-02-01 21:29:12 +01:00
*
2021-05-14 17:31:07 +02:00
* @return {Promise<object[]>} returns the app information document stored in each app database.
*/
exports.getAllApps = async ({ dev, all, idsOnly } = {}) => {
const CouchDB = getCouch()
let tenantId = getTenantId()
if (!env.MULTI_TENANCY && !tenantId) {
tenantId = DEFAULT_TENANT_ID
}
let dbs = await exports.getAllDbs()
const appDbNames = dbs.filter(dbName => {
const split = dbName.split(SEPARATOR)
// it is an app, check the tenantId
if (split[0] === DocumentTypes.APP) {
// tenantId is always right before the UUID
const possibleTenantId = split[split.length - 2]
2021-09-14 12:16:28 +02:00
const noTenantId =
split.length === 2 || possibleTenantId === DocumentTypes.DEV
return (
(tenantId === DEFAULT_TENANT_ID && noTenantId) ||
possibleTenantId === tenantId
)
}
return false
})
if (idsOnly) {
return appDbNames
}
const appPromises = appDbNames.map(app =>
// skip setup otherwise databases could be re-created
getAppMetadata(app, CouchDB)
2021-05-17 22:43:50 +02:00
)
2021-05-14 17:31:07 +02:00
if (appPromises.length === 0) {
return []
} else {
const response = await Promise.allSettled(appPromises)
const apps = response
.filter(result => result.status === "fulfilled" && result.value != null)
2021-05-14 17:31:07 +02:00
.map(({ value }) => value)
if (!all) {
return apps.filter(app => {
if (dev) {
return isDevApp(app)
}
return !isDevApp(app)
})
} else {
return apps.map(app => ({
...app,
status: isDevApp(app) ? "development" : "published",
}))
}
2021-05-14 17:31:07 +02:00
}
}
2021-11-10 13:00:29 +01:00
/**
* Utility function for getAllApps but filters to production apps only.
*/
exports.getProdAppIDs = async () => {
return (await exports.getAllApps({ idsOnly: true })).filter(
2021-11-10 13:00:29 +01:00
id => !exports.isDevAppID(id)
)
}
/**
* Utility function for the inverse of above.
*/
exports.getDevAppIDs = async () => {
return (await exports.getAllApps({ idsOnly: true })).filter(id =>
2021-11-10 13:00:29 +01:00
exports.isDevAppID(id)
)
}
exports.dbExists = async dbName => {
const CouchDB = getCouch()
let exists = false
try {
2021-06-08 19:06:56 +02:00
const db = CouchDB(dbName, { skip_setup: true })
// check if database exists
const info = await db.info()
if (info && !info.error) {
exists = true
}
} catch (err) {
exists = false
}
return exists
}
2021-04-20 19:14:36 +02:00
/**
* Generates a new configuration ID.
* @returns {string} The new configuration ID which the config doc can be stored under.
*/
const generateConfigID = ({ type, workspace, user }) => {
const scope = [type, workspace, user].filter(Boolean).join(SEPARATOR)
2021-04-20 19:14:36 +02:00
2021-04-22 12:45:22 +02:00
return `${DocumentTypes.CONFIG}${SEPARATOR}${scope}`
2021-04-20 19:14:36 +02:00
}
/**
* Gets parameters for retrieving configurations.
*/
const getConfigParams = ({ type, workspace, user }, otherProps = {}) => {
const scope = [type, workspace, user].filter(Boolean).join(SEPARATOR)
2021-04-22 12:45:22 +02:00
2021-04-20 19:14:36 +02:00
return {
...otherProps,
2021-04-22 12:45:22 +02:00
startkey: `${DocumentTypes.CONFIG}${SEPARATOR}${scope}`,
endkey: `${DocumentTypes.CONFIG}${SEPARATOR}${scope}${UNICODE_MAX}`,
2021-04-20 19:14:36 +02:00
}
}
2021-04-22 14:46:54 +02:00
/**
* Returns the most granular configuration document from the DB based on the type, workspace and userID passed.
2021-04-22 15:53:19 +02:00
* @param {Object} db - db instance to query
* @param {Object} scopes - the type, workspace and userID scopes of the configuration.
2021-04-22 14:46:54 +02:00
* @returns The most granular configuration document based on the scope.
*/
const getScopedFullConfig = async function (db, { type, user, workspace }) {
2021-04-22 14:46:54 +02:00
const response = await db.allDocs(
getConfigParams(
{ type, user, workspace },
2021-04-22 14:46:54 +02:00
{
include_docs: true,
}
)
)
2021-05-04 18:31:06 +02:00
function determineScore(row) {
2021-04-22 15:07:00 +02:00
const config = row.doc
2021-04-22 14:46:54 +02:00
// Config is specific to a user and a workspace
if (config._id.includes(generateConfigID({ type, user, workspace }))) {
2021-05-04 18:31:06 +02:00
return 4
2021-04-22 15:07:00 +02:00
} else if (config._id.includes(generateConfigID({ type, user }))) {
// Config is specific to a user only
2021-05-04 18:31:06 +02:00
return 3
} else if (config._id.includes(generateConfigID({ type, workspace }))) {
// Config is specific to a workspace only
2021-05-04 18:31:06 +02:00
return 2
2021-04-22 15:07:00 +02:00
} else if (config._id.includes(generateConfigID({ type }))) {
// Config is specific to a type only
2021-05-04 18:31:06 +02:00
return 1
2021-04-22 14:46:54 +02:00
}
2021-05-04 18:31:06 +02:00
return 0
}
2021-04-22 14:46:54 +02:00
2021-04-22 15:07:00 +02:00
// Find the config with the most granular scope based on context
let scopedConfig = response.rows.sort(
2021-05-04 19:14:13 +02:00
(a, b) => determineScore(a) - determineScore(b)
)[0]
2021-04-22 15:07:00 +02:00
// custom logic for settings doc
// always provide the platform URL
if (type === Configs.SETTINGS) {
if (scopedConfig && scopedConfig.doc) {
scopedConfig.doc.config.platformUrl = await getPlatformUrl(
scopedConfig.doc.config
)
} else {
scopedConfig = {
doc: {
config: {
platformUrl: await getPlatformUrl(),
},
},
}
}
}
return scopedConfig && scopedConfig.doc
2021-04-22 14:46:54 +02:00
}
const getPlatformUrl = async settings => {
let platformUrl = env.PLATFORM_URL || "http://localhost:10000"
if (!env.SELF_HOSTED && env.MULTI_TENANCY) {
// cloud and multi tenant - add the tenant to the default platform url
const tenantId = getTenantId()
if (!platformUrl.includes("localhost:")) {
platformUrl = platformUrl.replace("://", `://${tenantId}.`)
}
} else {
// self hosted - check for platform url override
if (settings && settings.platformUrl) {
platformUrl = settings.platformUrl
}
}
2021-12-10 16:18:57 +01:00
return platformUrl
}
async function getScopedConfig(db, params) {
const configDoc = await getScopedFullConfig(db, params)
return configDoc && configDoc.config ? configDoc.config : configDoc
}
function generateNewUsageQuotaDoc() {
return {
_id: StaticDatabases.GLOBAL.docs.usageQuota,
quotaReset: Date.now() + 2592000000,
usageQuota: {
automationRuns: 0,
rows: 0,
storage: 0,
apps: 0,
users: 0,
views: 0,
emails: 0,
},
usageLimits: {
automationRuns: 1000,
rows: 4000,
apps: 4,
storage: 1000,
users: 10,
emails: 50,
},
}
}
2021-05-13 12:06:08 +02:00
exports.Replication = Replication
exports.getScopedConfig = getScopedConfig
2021-04-22 14:46:54 +02:00
exports.generateConfigID = generateConfigID
exports.getConfigParams = getConfigParams
exports.getScopedFullConfig = getScopedFullConfig
exports.generateNewUsageQuotaDoc = generateNewUsageQuotaDoc