2023-07-26 14:12:58 +02:00
|
|
|
import validateJs from "validate.js"
|
2024-05-23 13:07:45 +02:00
|
|
|
import dayjs from "dayjs"
|
|
|
|
import cloneDeep from "lodash/fp/cloneDeep"
|
2024-02-29 13:33:03 +01:00
|
|
|
import {
|
2024-03-05 18:42:44 +01:00
|
|
|
Datasource,
|
|
|
|
DatasourcePlusQueryResponse,
|
2024-05-23 13:07:45 +02:00
|
|
|
FieldConstraints,
|
2024-02-29 13:33:03 +01:00
|
|
|
FieldType,
|
|
|
|
QueryJson,
|
|
|
|
Row,
|
2024-03-05 18:42:44 +01:00
|
|
|
SourceName,
|
2024-02-29 13:33:03 +01:00
|
|
|
Table,
|
|
|
|
TableSchema,
|
2024-05-16 18:33:47 +02:00
|
|
|
SqlClient,
|
2024-08-09 15:35:13 +02:00
|
|
|
ArrayOperator,
|
2024-09-24 13:30:45 +02:00
|
|
|
ViewV2,
|
2024-02-29 13:33:03 +01:00
|
|
|
} from "@budibase/types"
|
2023-07-14 17:04:59 +02:00
|
|
|
import { makeExternalQuery } from "../../../integrations/base/query"
|
|
|
|
import { Format } from "../../../api/controllers/view/exporters"
|
|
|
|
import sdk from "../.."
|
2024-09-24 13:30:45 +02:00
|
|
|
import { extractViewInfoFromID, isRelationshipColumn } from "../../../db/utils"
|
2024-05-16 18:33:47 +02:00
|
|
|
import { isSQL } from "../../../integrations/utils"
|
2024-10-02 11:05:56 +02:00
|
|
|
import { docIds, sql } from "@budibase/backend-core"
|
2024-09-24 14:01:33 +02:00
|
|
|
import { getTableFromSource } from "../../../api/controllers/row/utils"
|
2024-10-07 17:44:28 +02:00
|
|
|
import env from "../../../environment"
|
2024-03-05 17:19:21 +01:00
|
|
|
|
2024-03-05 18:42:44 +01:00
|
|
|
const SQL_CLIENT_SOURCE_MAP: Record<SourceName, SqlClient | undefined> = {
|
|
|
|
[SourceName.POSTGRES]: SqlClient.POSTGRES,
|
|
|
|
[SourceName.MYSQL]: SqlClient.MY_SQL,
|
|
|
|
[SourceName.SQL_SERVER]: SqlClient.MS_SQL,
|
|
|
|
[SourceName.ORACLE]: SqlClient.ORACLE,
|
|
|
|
[SourceName.DYNAMODB]: undefined,
|
|
|
|
[SourceName.MONGODB]: undefined,
|
|
|
|
[SourceName.ELASTICSEARCH]: undefined,
|
|
|
|
[SourceName.COUCHDB]: undefined,
|
|
|
|
[SourceName.S3]: undefined,
|
|
|
|
[SourceName.AIRTABLE]: undefined,
|
|
|
|
[SourceName.ARANGODB]: undefined,
|
|
|
|
[SourceName.REST]: undefined,
|
|
|
|
[SourceName.FIRESTORE]: undefined,
|
|
|
|
[SourceName.GOOGLE_SHEETS]: undefined,
|
|
|
|
[SourceName.REDIS]: undefined,
|
|
|
|
[SourceName.SNOWFLAKE]: undefined,
|
|
|
|
[SourceName.BUDIBASE]: undefined,
|
|
|
|
}
|
|
|
|
|
2024-10-07 17:47:49 +02:00
|
|
|
const XSS_INPUT_REGEX =
|
|
|
|
/[<>;"'(){}]|--|\/\*|\*\/|union|select|insert|drop|delete|update|exec|script/i
|
2024-10-07 17:44:28 +02:00
|
|
|
|
2024-03-05 17:19:21 +01:00
|
|
|
export function getSQLClient(datasource: Datasource): SqlClient {
|
2024-03-19 15:48:56 +01:00
|
|
|
if (!isSQL(datasource)) {
|
2024-03-05 17:19:21 +01:00
|
|
|
throw new Error("Cannot get SQL Client for non-SQL datasource")
|
|
|
|
}
|
2024-03-05 18:42:44 +01:00
|
|
|
const lookup = SQL_CLIENT_SOURCE_MAP[datasource.source]
|
|
|
|
if (lookup) {
|
|
|
|
return lookup
|
2024-03-05 17:19:21 +01:00
|
|
|
}
|
2024-03-05 18:42:44 +01:00
|
|
|
throw new Error("Unable to determine client for SQL datasource")
|
2024-03-05 17:19:21 +01:00
|
|
|
}
|
2023-07-14 17:04:59 +02:00
|
|
|
|
2024-06-19 15:28:22 +02:00
|
|
|
export function processRowCountResponse(
|
|
|
|
response: DatasourcePlusQueryResponse
|
|
|
|
): number {
|
2024-10-02 11:05:56 +02:00
|
|
|
if (
|
|
|
|
response &&
|
|
|
|
response.length === 1 &&
|
|
|
|
sql.COUNT_FIELD_NAME in response[0]
|
|
|
|
) {
|
|
|
|
const total = response[0][sql.COUNT_FIELD_NAME]
|
2024-06-19 15:28:22 +02:00
|
|
|
return typeof total === "number" ? total : parseInt(total)
|
|
|
|
} else {
|
|
|
|
throw new Error("Unable to count rows in query - no count response")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-02-29 13:33:03 +01:00
|
|
|
export async function getDatasourceAndQuery(
|
|
|
|
json: QueryJson
|
2024-02-28 18:03:59 +01:00
|
|
|
): Promise<DatasourcePlusQueryResponse> {
|
2023-07-14 17:04:59 +02:00
|
|
|
const datasourceId = json.endpoint.datasourceId
|
|
|
|
const datasource = await sdk.datasources.get(datasourceId)
|
2024-04-17 18:36:19 +02:00
|
|
|
const table = datasource.entities?.[json.endpoint.entityId]
|
|
|
|
if (!json.meta && table) {
|
|
|
|
json.meta = {
|
|
|
|
table,
|
|
|
|
}
|
|
|
|
}
|
2024-06-19 13:03:20 +02:00
|
|
|
return makeExternalQuery(datasource, json)
|
2023-07-14 17:04:59 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
export function cleanExportRows(
|
2024-07-31 13:28:28 +02:00
|
|
|
rows: Row[],
|
2023-07-17 15:57:12 +02:00
|
|
|
schema: TableSchema,
|
2023-07-14 17:04:59 +02:00
|
|
|
format: string,
|
2024-02-27 10:23:49 +01:00
|
|
|
columns?: string[],
|
|
|
|
customHeaders: { [key: string]: string } = {}
|
2023-07-14 17:04:59 +02:00
|
|
|
) {
|
|
|
|
let cleanRows = [...rows]
|
|
|
|
|
|
|
|
const relationships = Object.entries(schema)
|
2024-01-24 17:58:13 +01:00
|
|
|
.filter((entry: any[]) => entry[1].type === FieldType.LINK)
|
2023-07-14 17:04:59 +02:00
|
|
|
.map(entry => entry[0])
|
|
|
|
|
|
|
|
relationships.forEach(column => {
|
|
|
|
cleanRows.forEach(row => {
|
|
|
|
delete row[column]
|
|
|
|
})
|
|
|
|
delete schema[column]
|
|
|
|
})
|
|
|
|
|
|
|
|
if (format === Format.CSV) {
|
|
|
|
// Intended to append empty values in export
|
|
|
|
const schemaKeys = Object.keys(schema)
|
|
|
|
for (let key of schemaKeys) {
|
|
|
|
if (columns?.length && columns.indexOf(key) > 0) {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
for (let row of cleanRows) {
|
|
|
|
if (row[key] == null) {
|
|
|
|
row[key] = undefined
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2024-02-27 10:23:49 +01:00
|
|
|
} else if (format === Format.JSON) {
|
|
|
|
// Replace row keys with custom headers
|
|
|
|
for (let row of cleanRows) {
|
|
|
|
renameKeys(customHeaders, row)
|
|
|
|
}
|
2023-07-14 17:04:59 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
return cleanRows
|
|
|
|
}
|
2023-07-26 14:12:58 +02:00
|
|
|
|
2024-02-27 10:23:49 +01:00
|
|
|
function renameKeys(keysMap: { [key: string]: any }, row: any) {
|
|
|
|
for (const key in keysMap) {
|
|
|
|
Object.defineProperty(
|
|
|
|
row,
|
|
|
|
keysMap[key],
|
|
|
|
Object.getOwnPropertyDescriptor(row, key) || {}
|
|
|
|
)
|
|
|
|
delete row[key]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-07-26 14:12:58 +02:00
|
|
|
function isForeignKey(key: string, table: Table) {
|
2023-10-05 19:47:00 +02:00
|
|
|
const relationships = Object.values(table.schema).filter(isRelationshipColumn)
|
|
|
|
return relationships.some(
|
|
|
|
relationship => (relationship as any).foreignKey === key
|
2023-07-26 14:12:58 +02:00
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function validate({
|
2024-09-24 13:30:45 +02:00
|
|
|
source,
|
2023-07-26 14:12:58 +02:00
|
|
|
row,
|
|
|
|
}: {
|
2024-09-24 13:30:45 +02:00
|
|
|
source: Table | ViewV2
|
2023-07-26 14:12:58 +02:00
|
|
|
row: Row
|
|
|
|
}): Promise<{
|
|
|
|
valid: boolean
|
|
|
|
errors: Record<string, any>
|
|
|
|
}> {
|
2024-09-24 14:01:33 +02:00
|
|
|
const table = await getTableFromSource(source)
|
2023-07-26 14:12:58 +02:00
|
|
|
const errors: Record<string, any> = {}
|
2024-05-17 18:17:57 +02:00
|
|
|
const disallowArrayTypes = [
|
|
|
|
FieldType.ATTACHMENT_SINGLE,
|
|
|
|
FieldType.BB_REFERENCE_SINGLE,
|
|
|
|
]
|
2024-09-24 13:30:45 +02:00
|
|
|
for (let fieldName of Object.keys(table.schema)) {
|
|
|
|
const column = table.schema[fieldName]
|
2023-07-26 14:12:58 +02:00
|
|
|
const constraints = cloneDeep(column.constraints)
|
|
|
|
const type = column.type
|
|
|
|
// foreign keys are likely to be enriched
|
2024-09-24 13:30:45 +02:00
|
|
|
if (isForeignKey(fieldName, table)) {
|
2023-07-26 14:12:58 +02:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
// formulas shouldn't validated, data will be deleted anyway
|
2024-01-24 17:58:13 +01:00
|
|
|
if (type === FieldType.FORMULA || column.autocolumn) {
|
2023-07-26 14:12:58 +02:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
// special case for options, need to always allow unselected (empty)
|
2024-01-24 17:58:13 +01:00
|
|
|
if (type === FieldType.OPTIONS && constraints?.inclusion) {
|
2023-07-26 14:12:58 +02:00
|
|
|
constraints.inclusion.push(null as any, "")
|
|
|
|
}
|
2024-05-17 18:17:57 +02:00
|
|
|
|
|
|
|
if (disallowArrayTypes.includes(type) && Array.isArray(row[fieldName])) {
|
|
|
|
errors[fieldName] = `Cannot accept arrays`
|
|
|
|
}
|
2023-07-26 14:12:58 +02:00
|
|
|
let res
|
|
|
|
|
|
|
|
// Validate.js doesn't seem to handle array
|
2024-01-24 17:58:13 +01:00
|
|
|
if (type === FieldType.ARRAY && row[fieldName]) {
|
2023-07-26 14:12:58 +02:00
|
|
|
if (row[fieldName].length) {
|
|
|
|
if (!Array.isArray(row[fieldName])) {
|
|
|
|
row[fieldName] = row[fieldName].split(",")
|
|
|
|
}
|
|
|
|
row[fieldName].map((val: any) => {
|
|
|
|
if (
|
|
|
|
!constraints?.inclusion?.includes(val) &&
|
|
|
|
constraints?.inclusion?.length !== 0
|
|
|
|
) {
|
|
|
|
errors[fieldName] = "Field not in list"
|
|
|
|
}
|
|
|
|
})
|
|
|
|
} else if (constraints?.presence && row[fieldName].length === 0) {
|
|
|
|
// non required MultiSelect creates an empty array, which should not throw errors
|
|
|
|
errors[fieldName] = [`${fieldName} is required`]
|
|
|
|
}
|
|
|
|
} else if (
|
2024-04-03 17:05:18 +02:00
|
|
|
(type === FieldType.ATTACHMENTS || type === FieldType.JSON) &&
|
2023-07-26 14:12:58 +02:00
|
|
|
typeof row[fieldName] === "string"
|
|
|
|
) {
|
|
|
|
// this should only happen if there is an error
|
|
|
|
try {
|
|
|
|
const json = JSON.parse(row[fieldName])
|
2024-04-03 17:05:18 +02:00
|
|
|
if (type === FieldType.ATTACHMENTS) {
|
2023-07-26 14:12:58 +02:00
|
|
|
if (Array.isArray(json)) {
|
|
|
|
row[fieldName] = json
|
|
|
|
} else {
|
|
|
|
errors[fieldName] = [`Must be an array`]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} catch (err) {
|
|
|
|
errors[fieldName] = [`Contains invalid JSON`]
|
|
|
|
}
|
2024-05-23 11:33:41 +02:00
|
|
|
} else if (type === FieldType.DATETIME && column.timeOnly) {
|
2024-05-23 13:07:45 +02:00
|
|
|
res = validateTimeOnlyField(fieldName, row[fieldName], constraints)
|
2023-07-26 14:12:58 +02:00
|
|
|
} else {
|
|
|
|
res = validateJs.single(row[fieldName], constraints)
|
|
|
|
}
|
2024-10-07 17:44:28 +02:00
|
|
|
|
|
|
|
if (env.XSS_SAFE_MODE && typeof row[fieldName] === "string") {
|
|
|
|
if (XSS_INPUT_REGEX.test(row[fieldName])) {
|
2024-10-07 17:47:49 +02:00
|
|
|
errors[fieldName] = [
|
|
|
|
"Input not sanitised - potentially vulnerable to XSS",
|
|
|
|
]
|
2024-10-07 17:44:28 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-07-26 14:12:58 +02:00
|
|
|
if (res) errors[fieldName] = res
|
|
|
|
}
|
|
|
|
return { valid: Object.keys(errors).length === 0, errors }
|
|
|
|
}
|
2024-05-23 13:07:45 +02:00
|
|
|
|
|
|
|
function validateTimeOnlyField(
|
|
|
|
fieldName: string,
|
|
|
|
value: any,
|
|
|
|
constraints: FieldConstraints | undefined
|
|
|
|
) {
|
|
|
|
let res
|
|
|
|
if (value && !value.match(/^(\d+)(:[0-5]\d){1,2}$/)) {
|
2024-05-23 14:27:31 +02:00
|
|
|
res = [`"${fieldName}" is not a valid time`]
|
|
|
|
} else if (constraints) {
|
2024-05-23 13:07:45 +02:00
|
|
|
let castedValue = value
|
2024-05-23 14:47:54 +02:00
|
|
|
const stringTimeToDate = (value: string) => {
|
2024-05-23 14:27:31 +02:00
|
|
|
const [hour, minute, second] = value.split(":").map((x: string) => +x)
|
|
|
|
let date = dayjs("2000-01-01T00:00:00.000Z").hour(hour).minute(minute)
|
|
|
|
if (!isNaN(second)) {
|
|
|
|
date = date.second(second)
|
|
|
|
}
|
2024-05-23 14:47:54 +02:00
|
|
|
return date
|
2024-05-23 13:07:45 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
if (castedValue) {
|
2024-05-23 14:47:54 +02:00
|
|
|
castedValue = stringTimeToDate(castedValue)
|
2024-05-23 13:07:45 +02:00
|
|
|
}
|
|
|
|
let castedConstraints = cloneDeep(constraints)
|
2024-05-23 14:47:54 +02:00
|
|
|
|
|
|
|
let earliest, latest
|
2024-05-23 15:23:02 +02:00
|
|
|
let easliestTimeString: string, latestTimeString: string
|
2024-05-23 13:07:45 +02:00
|
|
|
if (castedConstraints.datetime?.earliest) {
|
2024-05-23 15:23:02 +02:00
|
|
|
easliestTimeString = castedConstraints.datetime.earliest
|
|
|
|
if (dayjs(castedConstraints.datetime.earliest).isValid()) {
|
|
|
|
easliestTimeString = dayjs(castedConstraints.datetime.earliest).format(
|
|
|
|
"HH:mm"
|
|
|
|
)
|
|
|
|
}
|
|
|
|
earliest = stringTimeToDate(easliestTimeString)
|
2024-05-23 13:07:45 +02:00
|
|
|
}
|
|
|
|
if (castedConstraints.datetime?.latest) {
|
2024-05-23 15:23:02 +02:00
|
|
|
latestTimeString = castedConstraints.datetime.latest
|
|
|
|
if (dayjs(castedConstraints.datetime.latest).isValid()) {
|
|
|
|
latestTimeString = dayjs(castedConstraints.datetime.latest).format(
|
|
|
|
"HH:mm"
|
|
|
|
)
|
|
|
|
}
|
|
|
|
latest = stringTimeToDate(latestTimeString)
|
2024-05-23 14:47:54 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
if (earliest && latest && earliest.isAfter(latest)) {
|
|
|
|
latest = latest.add(1, "day")
|
|
|
|
if (earliest.isAfter(castedValue)) {
|
|
|
|
castedValue = castedValue.add(1, "day")
|
|
|
|
}
|
2024-05-23 13:07:45 +02:00
|
|
|
}
|
|
|
|
|
2024-05-23 14:47:54 +02:00
|
|
|
if (earliest || latest) {
|
|
|
|
castedConstraints.datetime = {
|
|
|
|
earliest: earliest?.toISOString() || "",
|
|
|
|
latest: latest?.toISOString() || "",
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
let jsValidation = validateJs.single(
|
|
|
|
castedValue?.toISOString(),
|
|
|
|
castedConstraints
|
|
|
|
)
|
2024-05-23 13:07:45 +02:00
|
|
|
jsValidation = jsValidation?.map((m: string) =>
|
|
|
|
m
|
|
|
|
?.replace(
|
|
|
|
castedConstraints.datetime?.earliest || "",
|
2024-05-23 15:23:02 +02:00
|
|
|
easliestTimeString || ""
|
2024-05-23 13:07:45 +02:00
|
|
|
)
|
|
|
|
.replace(
|
|
|
|
castedConstraints.datetime?.latest || "",
|
2024-05-23 15:23:02 +02:00
|
|
|
latestTimeString || ""
|
2024-05-23 13:07:45 +02:00
|
|
|
)
|
|
|
|
)
|
|
|
|
if (jsValidation) {
|
|
|
|
res ??= []
|
|
|
|
res.push(...jsValidation)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return res
|
|
|
|
}
|
2024-08-09 15:35:13 +02:00
|
|
|
|
|
|
|
// type-guard check
|
|
|
|
export function isArrayFilter(operator: any): operator is ArrayOperator {
|
|
|
|
return Object.values(ArrayOperator).includes(operator)
|
|
|
|
}
|
2024-08-29 12:07:08 +02:00
|
|
|
|
|
|
|
export function tryExtractingTableAndViewId(tableOrViewId: string) {
|
2024-09-24 13:30:45 +02:00
|
|
|
if (docIds.isViewId(tableOrViewId)) {
|
2024-08-29 12:07:08 +02:00
|
|
|
return {
|
|
|
|
tableId: extractViewInfoFromID(tableOrViewId).tableId,
|
|
|
|
viewId: tableOrViewId,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return { tableId: tableOrViewId }
|
|
|
|
}
|
2024-09-24 17:35:53 +02:00
|
|
|
|
|
|
|
export function getSource(tableOrViewId: string) {
|
|
|
|
if (docIds.isViewId(tableOrViewId)) {
|
|
|
|
return sdk.views.get(tableOrViewId)
|
|
|
|
}
|
|
|
|
return sdk.tables.getTable(tableOrViewId)
|
|
|
|
}
|