Merge branch 'develop' of github.com:Budibase/budibase into grid-block

This commit is contained in:
Andrew Kingston 2023-06-16 10:02:42 +01:00
commit 70eda7ff81
58 changed files with 1405 additions and 956 deletions

View File

@ -12,31 +12,22 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# - name: Fail if not a tag - name: Fail if not a tag
# run: |
# if [[ $GITHUB_REF != refs/tags/* ]]; then
# echo "Workflow Dispatch can only be run on tags"
# exit 1
# fi
- uses: actions/checkout@v2
# with:
# fetch-depth: 0
# - name: Fail if tag is not in master
# run: |
# if ! git merge-base --is-ancestor ${{ github.sha }} origin/master; then
# echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch"
# exit 1
# fi
- name: Pull values.yaml from budibase-infra
run: | run: |
curl -H "Authorization: token ${{ secrets.GH_ACCESS_TOKEN }}" \ if [[ $GITHUB_REF != refs/tags/* ]]; then
-H 'Accept: application/vnd.github.v3.raw' \ echo "Workflow Dispatch can only be run on tags"
-o values.production.yaml \ exit 1
-L https://api.github.com/repos/budibase/budibase-infra/contents/kubernetes/values.yaml fi
wc -l values.production.yaml - uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Fail if tag is not in master
run: |
if ! git merge-base --is-ancestor ${{ github.sha }} origin/master; then
echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch"
exit 1
fi
- name: Get the latest budibase release version - name: Get the latest budibase release version
id: version id: version
@ -48,29 +39,10 @@ jobs:
fi fi
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
- name: Configure AWS Credentials - uses: passeidireto/trigger-external-workflow-action@main
uses: aws-actions/configure-aws-credentials@v1 env:
PAYLOAD_VERSION: ${{ env.RELEASE_VERSION }}
with: with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} repository: budibase/budibase-deploys
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} event: budicloud-prod-deploy
aws-region: eu-west-1 github_pat: ${{ secrets.GH_ACCESS_TOKEN }}
- name: Deploy to EKS
uses: craftech-io/eks-helm-deploy-action@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS__KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: eu-west-1
cluster-name: budibase-eks-production
config-files: values.production.yaml
chart-path: charts/budibase
namespace: budibase
values: globals.appVersion=v${{ env.RELEASE_VERSION }},services.couchdb.url=${{ secrets.PRODUCTION_COUCHDB_URL }},services.couchdb.password=${{ secrets.PRODUCTION_COUCHDB_PASSWORD }}
name: budibase-prod
- name: Discord Webhook Action
uses: tsickert/discord-webhook@v4.0.0
with:
webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }}
content: "Production Deployment Complete: ${{ env.RELEASE_VERSION }} deployed to Budibase Cloud."
embed-title: ${{ env.RELEASE_VERSION }}

View File

@ -24,51 +24,18 @@ jobs:
echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch" echo "Tag is not in master. This pipeline can only execute tags that are present on the master branch"
exit 1 exit 1
fi fi
- name: Get the latest budibase release version - name: Get the latest budibase release version
id: version id: version
run: | run: |
release_version=$(cat lerna.json | jq -r '.version') release_version=$(cat lerna.json | jq -r '.version')
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: eu-west-1
- name: Pull values.yaml from budibase-infra - uses: passeidireto/trigger-external-workflow-action@main
run: |
curl -H "Authorization: token ${{ secrets.GH_ACCESS_TOKEN }}" \
-H 'Accept: application/vnd.github.v3.raw' \
-o values.preprod.yaml \
-L https://api.github.com/repos/budibase/budibase-infra/contents/kubernetes/budibase-preprod/values.yaml
wc -l values.preprod.yaml
- name: Deploy to Preprod Environment
uses: budibase/helm@v1.8.0
with:
release: budibase-preprod
namespace: budibase
chart: charts/budibase
token: ${{ github.token }}
helm: helm3
values: |
globals:
appVersion: v${{ env.RELEASE_VERSION }}
ingress:
enabled: true
nginx: true
value-files: >-
[
"values.preprod.yaml"
]
env: env:
KUBECONFIG_FILE: '${{ secrets.PREPROD_KUBECONFIG }}' PAYLOAD_VERSION: ${{ env.RELEASE_VERSION }}
- name: Discord Webhook Action
uses: tsickert/discord-webhook@v4.0.0
with: with:
webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }} repository: budibase/budibase-deploys
content: "Preprod Deployment Complete: ${{ env.RELEASE_VERSION }} deployed to Budibase Pre-prod." event: budicloud-preprod-deploy
embed-title: ${{ env.RELEASE_VERSION }} github_pat: ${{ secrets.GH_ACCESS_TOKEN }}

View File

@ -1,5 +1,5 @@
{ {
"version": "2.7.7-alpha.5", "version": "2.7.21-alpha.2",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/backend-core", "packages/backend-core",
@ -16,7 +16,6 @@
"packages/worker", "packages/worker",
"packages/pro/packages/pro" "packages/pro/packages/pro"
], ],
"useWorkspaces": true,
"command": { "command": {
"publish": { "publish": {
"ignoreChanges": [ "ignoreChanges": [

View File

@ -15,10 +15,10 @@
"husky": "^8.0.3", "husky": "^8.0.3",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"kill-port": "^1.6.1", "kill-port": "^1.6.1",
"lerna": "7.0.0-alpha.0", "lerna": "^7.0.1",
"madge": "^6.0.0", "madge": "^6.0.0",
"minimist": "^1.2.8", "minimist": "^1.2.8",
"nx": "^16.2.1", "nx": "^16.3.2",
"prettier": "^2.3.1", "prettier": "^2.3.1",
"prettier-plugin-svelte": "^2.3.0", "prettier-plugin-svelte": "^2.3.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",

View File

@ -343,6 +343,9 @@ export class QueryBuilder<T> {
} }
const oneOf = (key: string, value: any) => { const oneOf = (key: string, value: any) => {
if (!value) {
return `*:*`
}
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
if (typeof value === "string") { if (typeof value === "string") {
value = value.split(",") value = value.split(",")

View File

@ -114,6 +114,25 @@ describe("lucene", () => {
expect(resp.rows.length).toBe(2) expect(resp.rows.length).toBe(2)
}) })
it("should return all rows when doing a one of search against falsey value", async () => {
const builder = new QueryBuilder(dbName, INDEX_NAME)
builder.addOneOf("property", null)
let resp = await builder.run()
expect(resp.rows.length).toBe(3)
builder.addOneOf("property", undefined)
resp = await builder.run()
expect(resp.rows.length).toBe(3)
builder.addOneOf("property", "")
resp = await builder.run()
expect(resp.rows.length).toBe(3)
builder.addOneOf("property", [])
resp = await builder.run()
expect(resp.rows.length).toBe(0)
})
it("should be able to perform a contains search", async () => { it("should be able to perform a contains search", async () => {
const builder = new QueryBuilder(dbName, INDEX_NAME) const builder = new QueryBuilder(dbName, INDEX_NAME)
builder.addContains("property", ["word"]) builder.addContains("property", ["word"])

View File

@ -1,12 +1,17 @@
import crypto from "crypto" import crypto from "crypto"
import fs from "fs"
import zlib from "zlib"
import env from "../environment" import env from "../environment"
import { join } from "path"
const ALGO = "aes-256-ctr" const ALGO = "aes-256-ctr"
const SEPARATOR = "-" const SEPARATOR = "-"
const ITERATIONS = 10000 const ITERATIONS = 10000
const RANDOM_BYTES = 16
const STRETCH_LENGTH = 32 const STRETCH_LENGTH = 32
const SALT_LENGTH = 16
const IV_LENGTH = 16
export enum SecretOption { export enum SecretOption {
API = "api", API = "api",
ENCRYPTION = "encryption", ENCRYPTION = "encryption",
@ -31,15 +36,15 @@ export function getSecret(secretOption: SecretOption): string {
return secret return secret
} }
function stretchString(string: string, salt: Buffer) { function stretchString(secret: string, salt: Buffer) {
return crypto.pbkdf2Sync(string, salt, ITERATIONS, STRETCH_LENGTH, "sha512") return crypto.pbkdf2Sync(secret, salt, ITERATIONS, STRETCH_LENGTH, "sha512")
} }
export function encrypt( export function encrypt(
input: string, input: string,
secretOption: SecretOption = SecretOption.API secretOption: SecretOption = SecretOption.API
) { ) {
const salt = crypto.randomBytes(RANDOM_BYTES) const salt = crypto.randomBytes(SALT_LENGTH)
const stretched = stretchString(getSecret(secretOption), salt) const stretched = stretchString(getSecret(secretOption), salt)
const cipher = crypto.createCipheriv(ALGO, stretched, salt) const cipher = crypto.createCipheriv(ALGO, stretched, salt)
const base = cipher.update(input) const base = cipher.update(input)
@ -60,3 +65,115 @@ export function decrypt(
const final = decipher.final() const final = decipher.final()
return Buffer.concat([base, final]).toString() return Buffer.concat([base, final]).toString()
} }
export async function encryptFile(
{ dir, filename }: { dir: string; filename: string },
secret: string
) {
const outputFileName = `${filename}.enc`
const filePath = join(dir, filename)
const inputFile = fs.createReadStream(filePath)
const outputFile = fs.createWriteStream(join(dir, outputFileName))
const salt = crypto.randomBytes(SALT_LENGTH)
const iv = crypto.randomBytes(IV_LENGTH)
const stretched = stretchString(secret, salt)
const cipher = crypto.createCipheriv(ALGO, stretched, iv)
outputFile.write(salt)
outputFile.write(iv)
inputFile.pipe(zlib.createGzip()).pipe(cipher).pipe(outputFile)
return new Promise<{ filename: string; dir: string }>(r => {
outputFile.on("finish", () => {
r({
filename: outputFileName,
dir,
})
})
})
}
async function getSaltAndIV(path: string) {
const fileStream = fs.createReadStream(path)
const salt = await readBytes(fileStream, SALT_LENGTH)
const iv = await readBytes(fileStream, IV_LENGTH)
fileStream.close()
return { salt, iv }
}
export async function decryptFile(
inputPath: string,
outputPath: string,
secret: string
) {
const { salt, iv } = await getSaltAndIV(inputPath)
const inputFile = fs.createReadStream(inputPath, {
start: SALT_LENGTH + IV_LENGTH,
})
const outputFile = fs.createWriteStream(outputPath)
const stretched = stretchString(secret, salt)
const decipher = crypto.createDecipheriv(ALGO, stretched, iv)
const unzip = zlib.createGunzip()
inputFile.pipe(decipher).pipe(unzip).pipe(outputFile)
return new Promise<void>((res, rej) => {
outputFile.on("finish", () => {
outputFile.close()
res()
})
inputFile.on("error", e => {
outputFile.close()
rej(e)
})
decipher.on("error", e => {
outputFile.close()
rej(e)
})
unzip.on("error", e => {
outputFile.close()
rej(e)
})
outputFile.on("error", e => {
outputFile.close()
rej(e)
})
})
}
function readBytes(stream: fs.ReadStream, length: number) {
return new Promise<Buffer>((resolve, reject) => {
let bytesRead = 0
const data: Buffer[] = []
stream.on("readable", () => {
let chunk
while ((chunk = stream.read(length - bytesRead)) !== null) {
data.push(chunk)
bytesRead += chunk.length
}
resolve(Buffer.concat(data))
})
stream.on("end", () => {
reject(new Error("Insufficient data in the stream."))
})
stream.on("error", error => {
reject(error)
})
})
}

View File

@ -140,9 +140,13 @@ export function lowerBuiltinRoleID(roleId1?: string, roleId2?: string): string {
* Gets the role object, this is mainly useful for two purposes, to check if the level exists and * Gets the role object, this is mainly useful for two purposes, to check if the level exists and
* to check if the role inherits any others. * to check if the role inherits any others.
* @param {string|null} roleId The level ID to lookup. * @param {string|null} roleId The level ID to lookup.
* @param {object|null} opts options for the function, like whether to halt errors, instead return public.
* @returns {Promise<Role|object|null>} The role object, which may contain an "inherits" property. * @returns {Promise<Role|object|null>} The role object, which may contain an "inherits" property.
*/ */
export async function getRole(roleId?: string): Promise<RoleDoc | undefined> { export async function getRole(
roleId?: string,
opts?: { defaultPublic?: boolean }
): Promise<RoleDoc | undefined> {
if (!roleId) { if (!roleId) {
return undefined return undefined
} }
@ -161,6 +165,9 @@ export async function getRole(roleId?: string): Promise<RoleDoc | undefined> {
// finalise the ID // finalise the ID
role._id = getExternalRoleID(role._id) role._id = getExternalRoleID(role._id)
} catch (err) { } catch (err) {
if (!isBuiltin(roleId) && opts?.defaultPublic) {
return cloneDeep(BUILTIN_ROLES.PUBLIC)
}
// only throw an error if there is no role at all // only throw an error if there is no role at all
if (Object.keys(role).length === 0) { if (Object.keys(role).length === 0) {
throw err throw err

View File

@ -8,6 +8,7 @@
export let fixed = false export let fixed = false
export let inline = false export let inline = false
export let disableCancel = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let visible = fixed || inline let visible = fixed || inline
@ -38,7 +39,7 @@
} }
export function cancel() { export function cancel() {
if (!visible) { if (!visible || disableCancel) {
return return
} }
dispatch("cancel") dispatch("cancel")

View File

@ -204,6 +204,12 @@
}) })
return columns return columns
.sort((a, b) => { .sort((a, b) => {
if (a.divider) {
return a
}
if (b.divider) {
return b
}
const orderA = a.order || Number.MAX_SAFE_INTEGER const orderA = a.order || Number.MAX_SAFE_INTEGER
const orderB = b.order || Number.MAX_SAFE_INTEGER const orderB = b.order || Number.MAX_SAFE_INTEGER
const nameA = getDisplayName(a) const nameA = getDisplayName(a)

View File

@ -5,9 +5,10 @@
<meta charset='utf8'> <meta charset='utf8'>
<meta name='viewport' content='width=device-width'> <meta name='viewport' content='width=device-width'>
<title>Budibase</title> <title>Budibase</title>
<link rel="preconnect" href="https://fonts.gstatic.com" /> <link href="/builder/fonts/source-sans-pro/400.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@400;600;700&display=swap" <link href="/builder/fonts/source-sans-pro/600.css" rel="stylesheet" />
rel="stylesheet" /> <link href="/builder/fonts/source-sans-pro/700.css" rel="stylesheet" />
<link href="/builder/fonts/remixicon.css" rel="stylesheet" />
</head> </head>
<body id="app"> <body id="app">

View File

@ -70,6 +70,7 @@
"@codemirror/state": "^6.2.0", "@codemirror/state": "^6.2.0",
"@codemirror/theme-one-dark": "^6.1.2", "@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.11.2", "@codemirror/view": "^6.11.2",
"@fontsource/source-sans-pro": "^5.0.3",
"@fortawesome/fontawesome-svg-core": "^6.2.1", "@fortawesome/fontawesome-svg-core": "^6.2.1",
"@fortawesome/free-brands-svg-icons": "^6.2.1", "@fortawesome/free-brands-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1", "@fortawesome/free-solid-svg-icons": "^6.2.1",
@ -122,6 +123,7 @@
"tsconfig-paths": "4.0.0", "tsconfig-paths": "4.0.0",
"typescript": "4.7.3", "typescript": "4.7.3",
"vite": "^3.0.8", "vite": "^3.0.8",
"vite-plugin-static-copy": "^0.16.0",
"vitest": "^0.29.2" "vitest": "^0.29.2"
}, },
"nx": { "nx": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 B

View File

@ -12,7 +12,7 @@
import { automationStore, selectedAutomation } from "builderStore" import { automationStore, selectedAutomation } from "builderStore"
import { admin, licensing } from "stores/portal" import { admin, licensing } from "stores/portal"
import { externalActions } from "./ExternalActions" import { externalActions } from "./ExternalActions"
import { TriggerStepID } from "constants/backend/automations" import { TriggerStepID, ActionStepID } from "constants/backend/automations"
import { checkForCollectStep } from "builderStore/utils" import { checkForCollectStep } from "builderStore/utils"
export let blockIdx export let blockIdx
@ -149,7 +149,7 @@
<div class="item-body"> <div class="item-body">
<Icon name={action.icon} /> <Icon name={action.icon} />
<Body size="XS">{action.name}</Body> <Body size="XS">{action.name}</Body>
{#if isDisabled && !syncAutomationsEnabled} {#if isDisabled && !syncAutomationsEnabled && action.stepId === ActionStepID.COLLECT}
<div class="tag-color"> <div class="tag-color">
<Tags> <Tags>
<Tag icon="LockClosed">Business</Tag> <Tag icon="LockClosed">Business</Tag>

View File

@ -76,6 +76,10 @@ export function getBindings({
// will be replaced by the main array binding // will be replaced by the main array binding
readableBinding: label, readableBinding: label,
runtimeBinding: binding, runtimeBinding: binding,
display: {
name: label,
type: field.name === FIELDS.LINK.name ? "Array" : field.name,
},
}) })
} }
return bindings return bindings

View File

@ -57,6 +57,12 @@
} }
async function saveDatasource() { async function saveDatasource() {
if (integration.features?.[DatasourceFeature.CONNECTION_CHECKING]) {
const valid = await validateConfig()
if (!valid) {
return false
}
}
try { try {
if (!datasource.name) { if (!datasource.name) {
datasource.name = name datasource.name = name

View File

@ -19,7 +19,7 @@
readableToRuntimeBinding, readableToRuntimeBinding,
runtimeToReadableBinding, runtimeToReadableBinding,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import { store } from "builderStore"
import { convertToJS } from "@budibase/string-templates" import { convertToJS } from "@budibase/string-templates"
import { admin } from "stores/portal" import { admin } from "stores/portal"
import CodeEditor from "../CodeEditor/CodeEditor.svelte" import CodeEditor from "../CodeEditor/CodeEditor.svelte"
@ -339,25 +339,28 @@
</Tab> </Tab>
{/if} {/if}
<div class="drawer-actions"> <div class="drawer-actions">
<Button {#if drawerActions?.hide}
secondary <Button
quiet secondary
on:click={() => { quiet
store.actions.settings.propertyFocus(null) on:click={() => {
drawerActions.hide() drawerActions.hide()
}} }}
> >
Cancel Cancel
</Button> </Button>
<Button {/if}
cta {#if bindingDrawerActions?.save}
disabled={!valid} <Button
on:click={() => { cta
bindingDrawerActions.save() disabled={!valid}
}} on:click={() => {
> bindingDrawerActions.save()
Save }}
</Button> >
Save
</Button>
{/if}
</div> </div>
</Tabs> </Tabs>
</div> </div>

View File

@ -36,7 +36,7 @@
.map(([name, categoryBindings]) => ({ .map(([name, categoryBindings]) => ({
name, name,
bindings: categoryBindings?.filter(binding => { bindings: categoryBindings?.filter(binding => {
return binding.readableBinding.match(searchRgx) return !search || binding.readableBinding.match(searchRgx)
}), }),
})) }))
.filter(category => { .filter(category => {
@ -46,7 +46,11 @@
) )
}) })
$: filteredHelpers = helpers?.filter(helper => { $: filteredHelpers = helpers?.filter(helper => {
return helper.label.match(searchRgx) || helper.description.match(searchRgx) return (
!search ||
helper.label.match(searchRgx) ||
helper.description.match(searchRgx)
)
}) })
const getHelperExample = (helper, js) => { const getHelperExample = (helper, js) => {
@ -124,9 +128,6 @@
<span <span
class="search-input-icon" class="search-input-icon"
on:click={() => { on:click={() => {
if (!search) {
return
}
search = null search = null
}} }}
class:searching={search} class:searching={search}

View File

@ -76,7 +76,7 @@
{/if} {/if}
</div> </div>
<Drawer bind:this={bindingDrawer} {title}> <Drawer bind:this={bindingDrawer} {title} headless>
<svelte:fragment slot="description"> <svelte:fragment slot="description">
Add the objects on the left to enrich your text. Add the objects on the left to enrich your text.
</svelte:fragment> </svelte:fragment>

View File

@ -5,8 +5,6 @@
runtimeToReadableBinding, runtimeToReadableBinding,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import { store } from "builderStore"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte" import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { createEventDispatcher, setContext } from "svelte" import { createEventDispatcher, setContext } from "svelte"
import { isJSBinding } from "@budibase/string-templates" import { isJSBinding } from "@budibase/string-templates"
@ -36,7 +34,6 @@
const saveBinding = () => { const saveBinding = () => {
onChange(tempValue) onChange(tempValue)
store.actions.settings.propertyFocus(null)
onBlur() onBlur()
bindingDrawer.hide() bindingDrawer.hide()
} }
@ -70,7 +67,6 @@
<div <div
class="icon" class="icon"
on:click={() => { on:click={() => {
store.actions.settings.propertyFocus(key)
bindingDrawer.show() bindingDrawer.show()
}} }}
> >

View File

@ -73,10 +73,6 @@
if (highlighted) { if (highlighted) {
store.actions.settings.highlight(null) store.actions.settings.highlight(null)
} }
// To fix focus 'affect' when property is target of a drawer other actions in the builder.
if (propertyFocus) {
store.actions.settings.propertyFocus(null)
}
}) })
</script> </script>

View File

@ -186,7 +186,6 @@
} }
div :global(.CodeMirror) { div :global(.CodeMirror) {
width: var(--code-mirror-width) !important;
height: var(--code-mirror-height) !important; height: var(--code-mirror-height) !important;
border-radius: var(--border-radius-s); border-radius: var(--border-radius-s);
font-family: var(--font-mono); font-family: var(--font-mono);

View File

@ -1,27 +1,128 @@
<script> <script>
import { ModalContent, Toggle, Body, InlineAlert } from "@budibase/bbui" import {
ModalContent,
Toggle,
Body,
InlineAlert,
Input,
notifications,
} from "@budibase/bbui"
import { createValidationStore } from "helpers/validation/yup"
export let app export let app
export let published export let published
let excludeRows = false let includeInternalTablesRows = true
let encypt = true
$: title = published ? "Export published app" : "Export latest app" let password = null
$: confirmText = published ? "Export published" : "Export latest" const validation = createValidationStore()
validation.addValidatorType("password", "password", true)
$: validation.observe("password", password)
const exportApp = () => { const Step = { CONFIG: "config", SET_PASSWORD: "set_password" }
let currentStep = Step.CONFIG
$: exportButtonText = published ? "Export published" : "Export latest"
$: stepConfig = {
[Step.CONFIG]: {
title: published ? "Export published app" : "Export latest app",
confirmText: encypt ? "Continue" : exportButtonText,
onConfirm: () => {
if (!encypt) {
exportApp()
} else {
currentStep = Step.SET_PASSWORD
return false
}
},
isValid: true,
},
[Step.SET_PASSWORD]: {
title: "Add password to encrypt your export",
confirmText: exportButtonText,
onConfirm: async () => {
await validation.check({ password })
if (!$validation.valid) {
return false
}
exportApp(password)
},
isValid: $validation.valid,
},
}
const exportApp = async () => {
const id = published ? app.prodId : app.devId const id = published ? app.prodId : app.devId
const appName = encodeURIComponent(app.name) const url = `/api/backups/export?appId=${id}`
window.location = `/api/backups/export?appId=${id}&appname=${appName}&excludeRows=${excludeRows}` await downloadFile(url, {
excludeRows: !includeInternalTablesRows,
encryptPassword: password,
})
}
async function downloadFile(url, body) {
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
})
if (response.ok) {
const contentDisposition = response.headers.get("Content-Disposition")
const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(
contentDisposition
)
const filename = matches[1].replace(/['"]/g, "")
const url = URL.createObjectURL(await response.blob())
const link = document.createElement("a")
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
} else {
notifications.error("Error exporting the app.")
}
} catch (error) {
notifications.error(error.message || "Error downloading the exported app")
}
} }
</script> </script>
<ModalContent {title} {confirmText} onConfirm={exportApp}> <ModalContent
<InlineAlert title={stepConfig[currentStep].title}
header="Do not share your budibase application exports publicly as they may contain sensitive information such as database credentials or secret keys." confirmText={stepConfig[currentStep].confirmText}
/> onConfirm={stepConfig[currentStep].onConfirm}
<Body disabled={!stepConfig[currentStep].isValid}
>Apps can be exported with or without data that is within internal tables - >
select this below.</Body {#if currentStep === Step.CONFIG}
> <Body>
<Toggle text="Exclude Rows" bind:value={excludeRows} /> <Toggle
text="Export rows from internal tables"
bind:value={includeInternalTablesRows}
/>
<Toggle text="Encrypt my export" bind:value={encypt} />
</Body>
{#if !encypt}
<InlineAlert
header="Do not share your budibase application exports publicly as they may contain sensitive information such as database credentials or secret keys."
/>
{/if}
{/if}
{#if currentStep === Step.SET_PASSWORD}
<Input
type="password"
label="Password"
placeholder="Type here..."
bind:value={password}
error={$validation.errors.password}
/>
{/if}
</ModalContent> </ModalContent>

View File

@ -6,7 +6,6 @@ export function createValidationStore(initialValue, ...validators) {
let touched = false let touched = false
const value = writable(initialValue || "") const value = writable(initialValue || "")
const error = derived(value, $v => validate($v, validators))
const touchedStore = derived(value, () => { const touchedStore = derived(value, () => {
if (!touched) { if (!touched) {
touched = true touched = true
@ -14,6 +13,10 @@ export function createValidationStore(initialValue, ...validators) {
} }
return touched return touched
}) })
const error = derived(
[value, touchedStore],
([$v, $t]) => $t && validate($v, validators)
)
return [value, error, touchedStore] return [value, error, touchedStore]
} }

View File

@ -5,6 +5,7 @@ import { notifications } from "@budibase/bbui"
export const createValidationStore = () => { export const createValidationStore = () => {
const DEFAULT = { const DEFAULT = {
values: {},
errors: {}, errors: {},
touched: {}, touched: {},
valid: false, valid: false,
@ -33,6 +34,9 @@ export const createValidationStore = () => {
case "email": case "email":
propertyValidator = string().email().nullable() propertyValidator = string().email().nullable()
break break
case "password":
propertyValidator = string().nullable()
break
default: default:
propertyValidator = string().nullable() propertyValidator = string().nullable()
} }
@ -41,9 +45,68 @@ export const createValidationStore = () => {
propertyValidator = propertyValidator.required() propertyValidator = propertyValidator.required()
} }
// We want to do this after the possible required validation, to prioritise the required error
switch (type) {
case "password":
propertyValidator = propertyValidator.min(8)
break
}
validator[propertyName] = propertyValidator validator[propertyName] = propertyValidator
} }
const observe = async (propertyName, value) => {
const values = get(validation).values
let fieldIsValid
if (!values.hasOwnProperty(propertyName)) {
// Initial setup
values[propertyName] = value
return
}
if (value === values[propertyName]) {
return
}
const obj = object().shape(validator)
try {
validation.update(store => {
store.errors[propertyName] = null
return store
})
await obj.validateAt(propertyName, { [propertyName]: value })
fieldIsValid = true
} catch (error) {
const [fieldError] = error.errors
if (fieldError) {
validation.update(store => {
store.errors[propertyName] = capitalise(fieldError)
store.valid = false
return store
})
}
}
if (fieldIsValid) {
// Validate the rest of the fields
try {
await obj.validate(
{ ...values, [propertyName]: value },
{ abortEarly: false }
)
validation.update(store => {
store.valid = true
return store
})
} catch {
validation.update(store => {
store.valid = false
return store
})
}
}
}
const check = async values => { const check = async values => {
const obj = object().shape(validator) const obj = object().shape(validator)
// clear the previous errors // clear the previous errors
@ -87,5 +150,6 @@ export const createValidationStore = () => {
check, check,
addValidator, addValidator,
addValidatorType, addValidatorType,
observe,
} }
} }

View File

@ -8,7 +8,7 @@
$: platformTitle = $: platformTitle =
!$auth.user && platformTitleText ? platformTitleText : "Budibase" !$auth.user && platformTitleText ? platformTitleText : "Budibase"
$: faviconUrl = $organisation.faviconUrl || "https://i.imgur.com/Xhdt1YP.png" $: faviconUrl = $organisation.faviconUrl || "/builder/bblogo.png"
onMount(async () => { onMount(async () => {
await organisation.init() await organisation.init()
@ -27,6 +27,6 @@
<link rel="icon" href={faviconUrl} /> <link rel="icon" href={faviconUrl} />
{:else} {:else}
<!-- A default must be set or the browser defaults to favicon.ico behaviour --> <!-- A default must be set or the browser defaults to favicon.ico behaviour -->
<link rel="icon" href={"https://i.imgur.com/Xhdt1YP.png"} /> <link rel="icon" href={"/builder/bblogo.png"} />
{/if} {/if}
</svelte:head> </svelte:head>

View File

@ -22,6 +22,7 @@
import ImportRestQueriesModal from "components/backend/DatasourceNavigator/modals/ImportRestQueriesModal.svelte" import ImportRestQueriesModal from "components/backend/DatasourceNavigator/modals/ImportRestQueriesModal.svelte"
import { API } from "api" import { API } from "api"
import { DatasourceFeature } from "@budibase/types" import { DatasourceFeature } from "@budibase/types"
import Spinner from "components/common/Spinner.svelte"
const querySchema = { const querySchema = {
name: {}, name: {},
@ -33,6 +34,7 @@
let isValid = true let isValid = true
let integration, baseDatasource, datasource let integration, baseDatasource, datasource
let queryList let queryList
let loading = false
$: baseDatasource = $datasources.selected $: baseDatasource = $datasources.selected
$: queryList = $queries.list.filter( $: queryList = $queries.list.filter(
@ -65,9 +67,11 @@
} }
const saveDatasource = async () => { const saveDatasource = async () => {
if (integration.features[DatasourceFeature.CONNECTION_CHECKING]) { loading = true
if (integration.features?.[DatasourceFeature.CONNECTION_CHECKING]) {
const valid = await validateConfig() const valid = await validateConfig()
if (!valid) { if (!valid) {
loading = false
return false return false
} }
} }
@ -82,6 +86,8 @@
baseDatasource = cloneDeep(datasource) baseDatasource = cloneDeep(datasource)
} catch (err) { } catch (err) {
notifications.error(`Error saving datasource: ${err}`) notifications.error(`Error saving datasource: ${err}`)
} finally {
loading = false
} }
} }
@ -119,8 +125,17 @@
<Divider /> <Divider />
<div class="config-header"> <div class="config-header">
<Heading size="S">Configuration</Heading> <Heading size="S">Configuration</Heading>
<Button disabled={!changed || !isValid} cta on:click={saveDatasource}> <Button
Save disabled={!changed || !isValid || loading}
cta
on:click={saveDatasource}
>
<div class="save-button-content">
{#if loading}
<Spinner size="10">Save</Spinner>
{/if}
Save
</div>
</Button> </Button>
</div> </div>
<IntegrationConfigForm <IntegrationConfigForm
@ -216,4 +231,10 @@
flex-direction: column; flex-direction: column;
gap: var(--spacing-m); gap: var(--spacing-m);
} }
.save-button-content {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
</style> </style>

View File

@ -44,7 +44,7 @@
<div tabindex="-1" class="exampleApp"> <div tabindex="-1" class="exampleApp">
<div class="page"> <div class="page">
<div class="header"> <div class="header">
<img alt="Budibase Logo" src={"https://i.imgur.com/Xhdt1YP.png"} /> <img alt="Budibase Logo" src={"/builder/bblogo.png"} />
<h1>{name}</h1> <h1>{name}</h1>
</div> </div>
<div class="nav">Home</div> <div class="nav">Home</div>

View File

@ -373,7 +373,7 @@
<OnboardingTypeModal {chooseCreationType} /> <OnboardingTypeModal {chooseCreationType} />
</Modal> </Modal>
<Modal bind:this={passwordModal}> <Modal bind:this={passwordModal} disableCancel={true}>
<PasswordModal <PasswordModal
createUsersResponse={bulkSaveResponse} createUsersResponse={bulkSaveResponse}
userData={userData.users} userData={userData.users}

View File

@ -1,6 +1,7 @@
import { svelte } from "@sveltejs/vite-plugin-svelte" import { svelte } from "@sveltejs/vite-plugin-svelte"
import replace from "@rollup/plugin-replace" import replace from "@rollup/plugin-replace"
import { defineConfig, loadEnv } from "vite" import { defineConfig, loadEnv } from "vite"
import { viteStaticCopy } from "vite-plugin-static-copy"
import path from "path" import path from "path"
const ignoredWarnings = [ const ignoredWarnings = [
@ -59,6 +60,18 @@ export default defineConfig(({ mode }) => {
), ),
"process.env.SENTRY_DSN": JSON.stringify(process.env.SENTRY_DSN), "process.env.SENTRY_DSN": JSON.stringify(process.env.SENTRY_DSN),
}), }),
viteStaticCopy({
targets: [
{
src: "../../node_modules/@fontsource/source-sans-pro",
dest: "fonts",
},
{
src: "../../node_modules/remixicon/fonts/*",
dest: "fonts",
},
],
}),
], ],
optimizeDeps: { optimizeDeps: {
exclude: ["@roxi/routify"], exclude: ["@roxi/routify"],

View File

@ -49,7 +49,7 @@
"pouchdb": "7.3.0", "pouchdb": "7.3.0",
"pouchdb-replication-stream": "1.2.9", "pouchdb-replication-stream": "1.2.9",
"randomstring": "1.1.5", "randomstring": "1.1.5",
"tar": "6.1.11", "tar": "6.1.15",
"yaml": "^2.1.1" "yaml": "^2.1.1"
}, },
"devDependencies": { "devDependencies": {

View File

@ -180,10 +180,7 @@
{/if} {/if}
<div class="logo"> <div class="logo">
{#if !hideLogo} {#if !hideLogo}
<img <img src={logoUrl || "/builder/bblogo.png"} alt={title} />
src={logoUrl || "https://i.imgur.com/Xhdt1YP.png"}
alt={title}
/>
{/if} {/if}
{#if !hideTitle && title} {#if !hideTitle && title}
<Heading size="S">{title}</Heading> <Heading size="S">{title}</Heading>

View File

@ -18,7 +18,7 @@
<img <img
class="logo" class="logo"
alt="logo" alt="logo"
src={logoUrl || "https://i.imgur.com/Xhdt1YP.png"} src={logoUrl || "/builder/bblogo.png"}
height="48" height="48"
/> />
</a> </a>

View File

@ -1,18 +1,30 @@
import { authStore } from "../stores/auth.js" import { authStore } from "../stores/auth.js"
import { appStore } from "../stores/app.js"
import { get } from "svelte/store" import { get } from "svelte/store"
import { Constants } from "@budibase/frontend-core" import { Constants } from "@budibase/frontend-core"
const getLicense = () => { const getUserLicense = () => {
const user = get(authStore) const user = get(authStore)
if (user) { if (user) {
return user.license return user.license
} }
} }
const getAppLicenseType = () => {
const appDef = get(appStore)
if (appDef?.licenseType) {
return appDef.licenseType
}
}
export const isFreePlan = () => { export const isFreePlan = () => {
const license = getLicense() let licenseType = getAppLicenseType()
if (license) { if (!licenseType) {
return license.plan.type === Constants.PlanType.FREE const license = getUserLicense()
licenseType = license?.plan?.type
}
if (licenseType) {
return licenseType === Constants.PlanType.FREE
} else { } else {
// safety net - no license means free plan // safety net - no license means free plan
return true return true

View File

@ -162,9 +162,9 @@
class:floating={offset > 0} class:floating={offset > 0}
style="--offset:{offset}px; --sticky-width:{width}px;" style="--offset:{offset}px; --sticky-width:{width}px;"
> >
<div class="underlay sticky" transition:fade={{ duration: 130 }} /> <div class="underlay sticky" transition:fade|local={{ duration: 130 }} />
<div class="underlay" transition:fade={{ duration: 130 }} /> <div class="underlay" transition:fade|local={{ duration: 130 }} />
<div class="sticky-column" transition:fade={{ duration: 130 }}> <div class="sticky-column" transition:fade|local={{ duration: 130 }}>
<GutterCell on:expand={addViaModal} rowHovered> <GutterCell on:expand={addViaModal} rowHovered>
<Icon name="Add" color="var(--spectrum-global-color-gray-500)" /> <Icon name="Add" color="var(--spectrum-global-color-gray-500)" />
{#if isAdding} {#if isAdding}
@ -193,7 +193,7 @@
</DataCell> </DataCell>
{/if} {/if}
</div> </div>
<div class="normal-columns" transition:fade={{ duration: 130 }}> <div class="normal-columns" transition:fade|local={{ duration: 130 }}>
<GridScrollWrapper scrollHorizontally wheelInteractive> <GridScrollWrapper scrollHorizontally wheelInteractive>
<div class="row"> <div class="row">
{#each $renderedColumns as column, columnIdx} {#each $renderedColumns as column, columnIdx}
@ -223,7 +223,7 @@
</div> </div>
</GridScrollWrapper> </GridScrollWrapper>
</div> </div>
<div class="buttons" transition:fade={{ duration: 130 }}> <div class="buttons" transition:fade|local={{ duration: 130 }}>
<Button size="M" cta on:click={addRow} disabled={isAdding}> <Button size="M" cta on:click={addRow} disabled={isAdding}>
<div class="button-with-keys"> <div class="button-with-keys">
Save Save

@ -1 +1 @@
Subproject commit 01fbc8670021c5a275c2a1a36ee18b984eeafad5 Subproject commit f4b8449aac9bd265214396afbdce7ff984a2ae34

View File

@ -117,7 +117,7 @@
"socket.io": "4.6.1", "socket.io": "4.6.1",
"svelte": "3.49.0", "svelte": "3.49.0",
"swagger-parser": "10.0.3", "swagger-parser": "10.0.3",
"tar": "6.1.11", "tar": "6.1.15",
"to-json-schema": "0.2.5", "to-json-schema": "0.2.5",
"uuid": "3.3.2", "uuid": "3.3.2",
"validate.js": "0.13.1", "validate.js": "0.13.1",
@ -150,7 +150,7 @@
"@types/redis": "4.0.11", "@types/redis": "4.0.11",
"@types/server-destroy": "1.0.1", "@types/server-destroy": "1.0.1",
"@types/supertest": "2.0.12", "@types/supertest": "2.0.12",
"@types/tar": "6.1.3", "@types/tar": "6.1.5",
"@typescript-eslint/parser": "5.45.0", "@typescript-eslint/parser": "5.45.0",
"apidoc": "0.50.4", "apidoc": "0.50.4",
"babel-jest": "29.5.0", "babel-jest": "29.5.0",

View File

@ -1,53 +1,54 @@
import env from "../../environment" import env from "../../environment"
import { import {
createAllSearchIndex,
createLinkView, createLinkView,
createRoutingView, createRoutingView,
createAllSearchIndex,
} from "../../db/views/staticViews" } from "../../db/views/staticViews"
import { createApp, deleteApp } from "../../utilities/fileSystem"
import { import {
backupClientLibrary,
createApp,
deleteApp,
revertClientLibrary,
updateClientLibrary,
} from "../../utilities/fileSystem"
import {
AppStatus,
DocumentType,
generateAppID, generateAppID,
generateDevAppID,
getLayoutParams, getLayoutParams,
getScreenParams, getScreenParams,
generateDevAppID,
DocumentType,
AppStatus,
} from "../../db/utils" } from "../../db/utils"
import { import {
db as dbCore,
roles,
cache, cache,
tenancy,
context, context,
db as dbCore,
env as envCore,
ErrorCode,
events, events,
migrations, migrations,
objectStore, objectStore,
ErrorCode, roles,
env as envCore, tenancy,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { USERS_TABLE_SCHEMA } from "../../constants" import { USERS_TABLE_SCHEMA } from "../../constants"
import { import {
DEFAULT_BB_DATASOURCE_ID,
buildDefaultDocs, buildDefaultDocs,
DEFAULT_BB_DATASOURCE_ID,
} from "../../db/defaultData/datasource_bb_default" } from "../../db/defaultData/datasource_bb_default"
import { removeAppFromUserRoles } from "../../utilities/workerRequests" import { removeAppFromUserRoles } from "../../utilities/workerRequests"
import { stringToReadStream, isQsTrue } from "../../utilities" import { stringToReadStream } from "../../utilities"
import { getLocksById, doesUserHaveLock } from "../../utilities/redis" import { doesUserHaveLock, getLocksById } from "../../utilities/redis"
import {
updateClientLibrary,
backupClientLibrary,
revertClientLibrary,
} from "../../utilities/fileSystem"
import { cleanupAutomations } from "../../automations/utils" import { cleanupAutomations } from "../../automations/utils"
import { checkAppMetadata } from "../../automations/logging" import { checkAppMetadata } from "../../automations/logging"
import { getUniqueRows } from "../../utilities/usageQuota/rows" import { getUniqueRows } from "../../utilities/usageQuota/rows"
import { quotas, groups } from "@budibase/pro" import { groups, licensing, quotas } from "@budibase/pro"
import { import {
App, App,
Layout, Layout,
Screen,
MigrationType, MigrationType,
Database, PlanType,
Screen,
UserCtx, UserCtx,
} from "@budibase/types" } from "@budibase/types"
import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts" import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts"
@ -207,6 +208,7 @@ export async function fetchAppPackage(ctx: UserCtx) {
let application = await db.get(DocumentType.APP_METADATA) let application = await db.get(DocumentType.APP_METADATA)
const layouts = await getLayouts() const layouts = await getLayouts()
let screens = await getScreens() let screens = await getScreens()
const license = await licensing.cache.getCachedLicense()
// Enrich plugin URLs // Enrich plugin URLs
application.usedPlugins = objectStore.enrichPluginURLs( application.usedPlugins = objectStore.enrichPluginURLs(
@ -227,6 +229,7 @@ export async function fetchAppPackage(ctx: UserCtx) {
ctx.body = { ctx.body = {
application: { ...application, upgradableVersion: envCore.VERSION }, application: { ...application, upgradableVersion: envCore.VERSION },
licenseType: license?.plan.type || PlanType.FREE,
screens, screens,
layouts, layouts,
clientLibPath, clientLibPath,

View File

@ -1,17 +1,31 @@
import sdk from "../../sdk" import sdk from "../../sdk"
import { events, context } from "@budibase/backend-core" import { events, context, db } from "@budibase/backend-core"
import { DocumentType } from "../../db/utils" import { DocumentType } from "../../db/utils"
import { isQsTrue } from "../../utilities" import { Ctx } from "@budibase/types"
interface ExportAppDumpRequest {
excludeRows: boolean
encryptPassword?: string
}
export async function exportAppDump(ctx: Ctx<ExportAppDumpRequest>) {
const { appId } = ctx.query as any
const { excludeRows, encryptPassword } = ctx.request.body
const [app] = await db.getAppsByIDs([appId])
const appName = app.name
export async function exportAppDump(ctx: any) {
let { appId, excludeRows } = ctx.query
// remove the 120 second limit for the request // remove the 120 second limit for the request
ctx.req.setTimeout(0) ctx.req.setTimeout(0)
const appName = decodeURI(ctx.query.appname)
excludeRows = isQsTrue(excludeRows) const extension = encryptPassword ? "enc.tar.gz" : "tar.gz"
const backupIdentifier = `${appName}-export-${new Date().getTime()}.tar.gz` const backupIdentifier = `${appName}-export-${new Date().getTime()}.${extension}`
ctx.attachment(backupIdentifier) ctx.attachment(backupIdentifier)
ctx.body = await sdk.backups.streamExportApp(appId, excludeRows) ctx.body = await sdk.backups.streamExportApp({
appId,
excludeRows,
encryptPassword,
})
await context.doInAppContext(appId, async () => { await context.doInAppContext(appId, async () => {
const appDb = context.getAppDB() const appDb = context.getAppDB()

View File

@ -4,7 +4,7 @@ import {
getUserMetadataParams, getUserMetadataParams,
InternalTables, InternalTables,
} from "../../db/utils" } from "../../db/utils"
import { BBContext, Database } from "@budibase/types" import { UserCtx, Database } from "@budibase/types"
const UpdateRolesOptions = { const UpdateRolesOptions = {
CREATED: "created", CREATED: "created",
@ -38,15 +38,15 @@ async function updateRolesOnUserTable(
} }
} }
export async function fetch(ctx: BBContext) { export async function fetch(ctx: UserCtx) {
ctx.body = await roles.getAllRoles() ctx.body = await roles.getAllRoles()
} }
export async function find(ctx: BBContext) { export async function find(ctx: UserCtx) {
ctx.body = await roles.getRole(ctx.params.roleId) ctx.body = await roles.getRole(ctx.params.roleId)
} }
export async function save(ctx: BBContext) { export async function save(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
let { _id, name, inherits, permissionId } = ctx.request.body let { _id, name, inherits, permissionId } = ctx.request.body
let isCreate = false let isCreate = false
@ -72,7 +72,7 @@ export async function save(ctx: BBContext) {
ctx.message = `Role '${role.name}' created successfully.` ctx.message = `Role '${role.name}' created successfully.`
} }
export async function destroy(ctx: BBContext) { export async function destroy(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
const roleId = ctx.params.roleId const roleId = ctx.params.roleId
const role = await db.get(roleId) const role = await db.get(roleId)

View File

@ -1,6 +1,6 @@
import { getRoutingInfo } from "../../utilities/routing" import { getRoutingInfo } from "../../utilities/routing"
import { roles } from "@budibase/backend-core" import { roles } from "@budibase/backend-core"
import { BBContext } from "@budibase/types" import { UserCtx } from "@budibase/types"
const URL_SEPARATOR = "/" const URL_SEPARATOR = "/"
@ -56,11 +56,11 @@ async function getRoutingStructure() {
return { routes: routing.json } return { routes: routing.json }
} }
export async function fetch(ctx: BBContext) { export async function fetch(ctx: UserCtx) {
ctx.body = await getRoutingStructure() ctx.body = await getRoutingStructure()
} }
export async function clientFetch(ctx: BBContext) { export async function clientFetch(ctx: UserCtx) {
const routing = await getRoutingStructure() const routing = await getRoutingStructure()
let roleId = ctx.user?.role?._id let roleId = ctx.user?.role?._id
const roleIds = (await roles.getUserRoleHierarchy(roleId, { const roleIds = (await roles.getUserRoleHierarchy(roleId, {

View File

@ -19,6 +19,7 @@ import {
breakRowIdField, breakRowIdField,
convertRowId, convertRowId,
generateRowIdField, generateRowIdField,
getPrimaryDisplay,
isRowId, isRowId,
isSQL, isSQL,
} from "../../../integrations/utils" } from "../../../integrations/utils"
@ -391,7 +392,10 @@ export class ExternalRequest {
} }
} }
relatedRow = processFormulas(linkedTable, relatedRow) relatedRow = processFormulas(linkedTable, relatedRow)
const relatedDisplay = display ? relatedRow[display] : undefined let relatedDisplay
if (display) {
relatedDisplay = getPrimaryDisplay(relatedRow[display])
}
row[relationship.column][key] = { row[relationship.column][key] = {
primaryDisplay: relatedDisplay || "Invalid display column", primaryDisplay: relatedDisplay || "Invalid display column",
_id: relatedRow._id, _id: relatedRow._id,

View File

@ -237,9 +237,15 @@ export async function exportRows(ctx: UserCtx) {
ctx.request.body = { ctx.request.body = {
query: { query: {
oneOf: { oneOf: {
_id: ctx.request.body.rows.map( _id: ctx.request.body.rows.map((row: string) => {
(row: string) => JSON.parse(decodeURI(row))[0] const ids = JSON.parse(
), decodeURI(row).replace(/'/g, `"`).replace(/%2C/g, ",")
)
if (ids.length > 1) {
ctx.throw(400, "Export data does not support composite keys.")
}
return ids[0]
}),
}, },
}, },
} }

View File

@ -40,19 +40,14 @@
{#if favicon !== ""} {#if favicon !== ""}
<link rel="icon" type="image/png" href={favicon} /> <link rel="icon" type="image/png" href={favicon} />
{:else} {:else}
<link rel="icon" type="image/png" href="https://i.imgur.com/Xhdt1YP.png" /> <link rel="icon" type="image/png" href="/builder/bblogo.png" />
{/if} {/if}
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" /> <link href="/builder/fonts/source-sans-pro/400.css" rel="stylesheet" />
<link rel="preconnect" href="https://fonts.gstatic.com" /> <link href="/builder/fonts/source-sans-pro/600.css" rel="stylesheet" />
<link <link href="/builder/fonts/source-sans-pro/700.css" rel="stylesheet" />
href="https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@400;600;700&display=swap" <link href="/builder/fonts/remixicon.css" rel="stylesheet" />
rel="stylesheet"
/>
<link
href="https://cdn.jsdelivr.net/npm/remixicon@2.5.0/fonts/remixicon.css"
rel="stylesheet"
/>
<style> <style>
html, html,
body { body {

View File

@ -1,16 +1,10 @@
<html lang="en"> <html lang="en">
<head> <head>
<title>Budibase Builder Preview</title> <title>Budibase Builder Preview</title>
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" /> <link href="/builder/fonts/source-sans-pro/400.css" rel="stylesheet" />
<link rel="preconnect" href="https://fonts.gstatic.com" /> <link href="/builder/fonts/source-sans-pro/600.css" rel="stylesheet" />
<link <link href="/builder/fonts/source-sans-pro/700.css" rel="stylesheet" />
href="https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@400;600;700&display=swap" <link href="/builder/fonts/remixicon.css" rel="stylesheet" />
rel="stylesheet"
/>
<link
href="https://cdn.jsdelivr.net/npm/remixicon@2.5.0/fonts/remixicon.css"
rel="stylesheet"
/>
<style> <style>
html, body { html, body {
padding: 0; padding: 0;

View File

@ -0,0 +1,120 @@
import { exportRows } from "../row/external"
import sdk from "../../../sdk"
import { ExternalRequest } from "../row/ExternalRequest"
// @ts-ignore
sdk.datasources = {
get: jest.fn(),
}
jest.mock("../row/ExternalRequest")
jest.mock("../view/exporters", () => ({
csv: jest.fn(),
Format: {
CSV: "csv",
},
}))
jest.mock("../../../utilities/fileSystem")
function getUserCtx() {
return {
params: {
tableId: "datasource__tablename",
},
query: {
format: "csv",
},
request: {
body: {},
},
throw: jest.fn(() => {
throw "Err"
}),
attachment: jest.fn(),
}
}
describe("external row controller", () => {
describe("exportRows", () => {
beforeAll(() => {
//@ts-ignore
jest.spyOn(ExternalRequest.prototype, "run").mockImplementation(() => [])
})
afterEach(() => {
jest.clearAllMocks()
})
it("should throw a 400 if no datasource entities are present", async () => {
let userCtx = getUserCtx()
try {
//@ts-ignore
await exportRows(userCtx)
} catch (e) {
expect(userCtx.throw).toHaveBeenCalledWith(
400,
"Datasource has not been configured for plus API."
)
}
})
it("should handle single quotes from a row ID", async () => {
//@ts-ignore
sdk.datasources.get.mockImplementation(() => ({
entities: {
tablename: {
schema: {},
},
},
}))
let userCtx = getUserCtx()
userCtx.request.body = {
rows: ["['d001']"],
}
//@ts-ignore
await exportRows(userCtx)
expect(userCtx.request.body).toEqual({
query: {
oneOf: {
_id: ["d001"],
},
},
})
})
it("should throw a 400 if any composite keys are present", async () => {
let userCtx = getUserCtx()
userCtx.request.body = {
rows: ["[123]", "['d001'%2C'10111']"],
}
try {
//@ts-ignore
await exportRows(userCtx)
} catch (e) {
expect(userCtx.throw).toHaveBeenCalledWith(
400,
"Export data does not support composite keys."
)
}
})
it("should throw a 400 if no table name was found", async () => {
let userCtx = getUserCtx()
userCtx.params.tableId = "datasource__"
userCtx.request.body = {
rows: ["[123]"],
}
try {
//@ts-ignore
await exportRows(userCtx)
} catch (e) {
expect(userCtx.throw).toHaveBeenCalledWith(
400,
"Could not find table name."
)
}
})
})
})

View File

@ -5,7 +5,7 @@ import { permissions } from "@budibase/backend-core"
const router: Router = new Router() const router: Router = new Router()
router.get( router.post(
"/api/backups/export", "/api/backups/export",
authorized(permissions.BUILDER), authorized(permissions.BUILDER),
controller.exportAppDump controller.exportAppDump

View File

@ -1,7 +1,9 @@
import tk from "timekeeper"
import * as setup from "./utilities" import * as setup from "./utilities"
import { events } from "@budibase/backend-core" import { events } from "@budibase/backend-core"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { checkBuilderEndpoint } from "./utilities/TestFunctions" import { checkBuilderEndpoint } from "./utilities/TestFunctions"
import { mocks } from "@budibase/backend-core/tests"
describe("/backups", () => { describe("/backups", () => {
let request = setup.getRequest() let request = setup.getRequest()
@ -16,7 +18,7 @@ describe("/backups", () => {
describe("exportAppDump", () => { describe("exportAppDump", () => {
it("should be able to export app", async () => { it("should be able to export app", async () => {
const res = await request const res = await request
.get(`/api/backups/export?appId=${config.getAppId()}&appname=test`) .post(`/api/backups/export?appId=${config.getAppId()}`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect(200) .expect(200)
expect(res.headers["content-type"]).toEqual("application/gzip") expect(res.headers["content-type"]).toEqual("application/gzip")
@ -26,10 +28,24 @@ describe("/backups", () => {
it("should apply authorization to endpoint", async () => { it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({ await checkBuilderEndpoint({
config, config,
method: "GET", method: "POST",
url: `/api/backups/export?appId=${config.getAppId()}`, url: `/api/backups/export?appId=${config.getAppId()}`,
}) })
}) })
it("should infer the app name from the app", async () => {
tk.freeze(mocks.date.MOCK_DATE)
const res = await request
.post(`/api/backups/export?appId=${config.getAppId()}`)
.set(config.defaultHeaders())
expect(res.headers["content-disposition"]).toEqual(
`attachment; filename="${
config.getApp()!.name
}-export-${mocks.date.MOCK_DATE.getTime()}.tar.gz"`
)
})
}) })
describe("calculateBackupStats", () => { describe("calculateBackupStats", () => {

View File

@ -91,7 +91,7 @@ const SCHEMA: Integration = {
}, },
} }
function bindingTypeCoerce(bindings: any[]) { export function bindingTypeCoerce(bindings: any[]) {
for (let i = 0; i < bindings.length; i++) { for (let i = 0; i < bindings.length; i++) {
const binding = bindings[i] const binding = bindings[i]
if (typeof binding !== "string") { if (typeof binding !== "string") {
@ -109,7 +109,12 @@ function bindingTypeCoerce(bindings: any[]) {
dayjs(binding).isValid() && dayjs(binding).isValid() &&
!binding.includes(",") !binding.includes(",")
) { ) {
bindings[i] = dayjs(binding).toDate() let value: any
value = new Date(binding)
if (isNaN(value)) {
value = binding
}
bindings[i] = value
} }
} }
return bindings return bindings

View File

@ -20,7 +20,7 @@ import Sql from "./base/sql"
import { PostgresColumn } from "./base/types" import { PostgresColumn } from "./base/types"
import { escapeDangerousCharacters } from "../utilities" import { escapeDangerousCharacters } from "../utilities"
import { Client, types } from "pg" import { Client, ClientConfig, types } from "pg"
// Return "date" and "timestamp" types as plain strings. // Return "date" and "timestamp" types as plain strings.
// This lets us reference the original stored timezone. // This lets us reference the original stored timezone.
@ -42,6 +42,8 @@ interface PostgresConfig {
schema: string schema: string
ssl?: boolean ssl?: boolean
ca?: string ca?: string
clientKey?: string
clientCert?: string
rejectUnauthorized?: boolean rejectUnauthorized?: boolean
} }
@ -98,6 +100,19 @@ const SCHEMA: Integration = {
required: false, required: false,
}, },
ca: { ca: {
display: "Server CA",
type: DatasourceFieldType.LONGFORM,
default: false,
required: false,
},
clientKey: {
display: "Client key",
type: DatasourceFieldType.LONGFORM,
default: false,
required: false,
},
clientCert: {
display: "Client cert",
type: DatasourceFieldType.LONGFORM, type: DatasourceFieldType.LONGFORM,
default: false, default: false,
required: false, required: false,
@ -144,12 +159,14 @@ class PostgresIntegration extends Sql implements DatasourcePlus {
super(SqlClient.POSTGRES) super(SqlClient.POSTGRES)
this.config = config this.config = config
let newConfig = { let newConfig: ClientConfig = {
...this.config, ...this.config,
ssl: this.config.ssl ssl: this.config.ssl
? { ? {
rejectUnauthorized: this.config.rejectUnauthorized, rejectUnauthorized: this.config.rejectUnauthorized,
ca: this.config.ca, ca: this.config.ca,
key: this.config.clientKey,
cert: this.config.clientCert,
} }
: undefined, : undefined,
} }

View File

@ -1,4 +1,4 @@
import { default as MySQLIntegration } from "../mysql" import { default as MySQLIntegration, bindingTypeCoerce } from "../mysql"
jest.mock("mysql2") jest.mock("mysql2")
class TestConfiguration { class TestConfiguration {
@ -131,3 +131,21 @@ describe("MySQL Integration", () => {
}) })
}) })
}) })
describe("bindingTypeCoercion", () => {
it("shouldn't coerce something that looks like a date", () => {
const response = bindingTypeCoerce(["202205-1500"])
expect(response[0]).toBe("202205-1500")
})
it("should coerce an actual date", () => {
const date = new Date("2023-06-13T14:24:22.620Z")
const response = bindingTypeCoerce(["2023-06-13T14:24:22.620Z"])
expect(response[0]).toEqual(date)
})
it("should coerce numbers", () => {
const response = bindingTypeCoerce(["0"])
expect(response[0]).toEqual(0)
})
})

View File

@ -328,3 +328,27 @@ export function finaliseExternalTables(
.reduce((r, [k, v]) => ({ ...r, [k]: v }), {}) .reduce((r, [k, v]) => ({ ...r, [k]: v }), {})
return { tables: finalTables, errors } return { tables: finalTables, errors }
} }
/**
* Checks if the provided input is an object, but specifically not a date type object.
* Used during coercion of types and relationship handling, dates are considered valid
* and can be used as a display field, but objects and arrays cannot.
* @param testValue an unknown type which this function will attempt to extract
* a valid primary display string from.
*/
export function getPrimaryDisplay(testValue: unknown): string | undefined {
if (testValue instanceof Date) {
return testValue.toISOString()
}
if (
Array.isArray(testValue) &&
testValue[0] &&
typeof testValue[0] !== "object"
) {
return testValue.join(", ")
}
if (typeof testValue === "object") {
return undefined
}
return testValue as string
}

View File

@ -103,7 +103,7 @@ export default async (ctx: UserCtx, next: any) => {
userId, userId,
globalId, globalId,
roleId, roleId,
role: await roles.getRole(roleId), role: await roles.getRole(roleId, { defaultPublic: true }),
} }
} }

View File

@ -1,4 +1,4 @@
import { db as dbCore, objectStore } from "@budibase/backend-core" import { db as dbCore, encryption, objectStore } from "@budibase/backend-core"
import { budibaseTempDir } from "../../../utilities/budibaseDir" import { budibaseTempDir } from "../../../utilities/budibaseDir"
import { streamFile, createTempFolder } from "../../../utilities/fileSystem" import { streamFile, createTempFolder } from "../../../utilities/fileSystem"
import { ObjectStoreBuckets } from "../../../constants" import { ObjectStoreBuckets } from "../../../constants"
@ -18,7 +18,8 @@ import { join } from "path"
import env from "../../../environment" import env from "../../../environment"
const uuid = require("uuid/v4") const uuid = require("uuid/v4")
const tar = require("tar") import tar from "tar"
const MemoryStream = require("memorystream") const MemoryStream = require("memorystream")
interface DBDumpOpts { interface DBDumpOpts {
@ -30,16 +31,18 @@ interface ExportOpts extends DBDumpOpts {
tar?: boolean tar?: boolean
excludeRows?: boolean excludeRows?: boolean
excludeLogs?: boolean excludeLogs?: boolean
encryptPassword?: string
} }
function tarFilesToTmp(tmpDir: string, files: string[]) { function tarFilesToTmp(tmpDir: string, files: string[]) {
const exportFile = join(budibaseTempDir(), `${uuid()}.tar.gz`) const fileName = `${uuid()}.tar.gz`
const exportFile = join(budibaseTempDir(), fileName)
tar.create( tar.create(
{ {
sync: true, sync: true,
gzip: true, gzip: true,
file: exportFile, file: exportFile,
recursive: true, noDirRecurse: false,
cwd: tmpDir, cwd: tmpDir,
}, },
files files
@ -124,6 +127,7 @@ export async function exportApp(appId: string, config?: ExportOpts) {
) )
} }
} }
const downloadedPath = join(tmpPath, appPath) const downloadedPath = join(tmpPath, appPath)
if (fs.existsSync(downloadedPath)) { if (fs.existsSync(downloadedPath)) {
const allFiles = fs.readdirSync(downloadedPath) const allFiles = fs.readdirSync(downloadedPath)
@ -141,12 +145,27 @@ export async function exportApp(appId: string, config?: ExportOpts) {
filter: defineFilter(config?.excludeRows, config?.excludeLogs), filter: defineFilter(config?.excludeRows, config?.excludeLogs),
exportPath: dbPath, exportPath: dbPath,
}) })
if (config?.encryptPassword) {
for (let file of fs.readdirSync(tmpPath)) {
const path = join(tmpPath, file)
await encryption.encryptFile(
{ dir: tmpPath, filename: file },
config.encryptPassword
)
fs.rmSync(path)
}
}
// if tar requested, return where the tarball is // if tar requested, return where the tarball is
if (config?.tar) { if (config?.tar) {
// now the tmpPath contains both the DB export and attachments, tar this // now the tmpPath contains both the DB export and attachments, tar this
const tarPath = tarFilesToTmp(tmpPath, fs.readdirSync(tmpPath)) const tarPath = tarFilesToTmp(tmpPath, fs.readdirSync(tmpPath))
// cleanup the tmp export files as tarball returned // cleanup the tmp export files as tarball returned
fs.rmSync(tmpPath, { recursive: true, force: true }) fs.rmSync(tmpPath, { recursive: true, force: true })
return tarPath return tarPath
} }
// tar not requested, turn the directory where export is // tar not requested, turn the directory where export is
@ -161,11 +180,20 @@ export async function exportApp(appId: string, config?: ExportOpts) {
* @param {boolean} excludeRows Flag to state whether the export should include data. * @param {boolean} excludeRows Flag to state whether the export should include data.
* @returns {*} a readable stream of the backup which is written in real time * @returns {*} a readable stream of the backup which is written in real time
*/ */
export async function streamExportApp(appId: string, excludeRows: boolean) { export async function streamExportApp({
appId,
excludeRows,
encryptPassword,
}: {
appId: string
excludeRows: boolean
encryptPassword?: string
}) {
const tmpPath = await exportApp(appId, { const tmpPath = await exportApp(appId, {
excludeRows, excludeRows,
excludeLogs: true, excludeLogs: true,
tar: true, tar: true,
encryptPassword,
}) })
return streamFile(tmpPath) return streamFile(tmpPath)
} }

View File

@ -1,4 +1,4 @@
import { db as dbCore, objectStore } from "@budibase/backend-core" import { db as dbCore, encryption, objectStore } from "@budibase/backend-core"
import { Database, Row } from "@budibase/types" import { Database, Row } from "@budibase/types"
import { getAutomationParams, TABLE_ROW_PREFIX } from "../../../db/utils" import { getAutomationParams, TABLE_ROW_PREFIX } from "../../../db/utils"
import { budibaseTempDir } from "../../../utilities/budibaseDir" import { budibaseTempDir } from "../../../utilities/budibaseDir"
@ -20,6 +20,7 @@ type TemplateType = {
file?: { file?: {
type: string type: string
path: string path: string
password?: string
} }
key?: string key?: string
} }
@ -123,6 +124,22 @@ export function untarFile(file: { path: string }) {
return tmpPath return tmpPath
} }
async function decryptFiles(path: string, password: string) {
try {
for (let file of fs.readdirSync(path)) {
const inputPath = join(path, file)
const outputPath = inputPath.replace(/\.enc$/, "")
await encryption.decryptFile(inputPath, outputPath, password)
fs.rmSync(inputPath)
}
} catch (err: any) {
if (err.message === "incorrect header check") {
throw new Error("File cannot be imported")
}
throw err
}
}
export function getGlobalDBFile(tmpPath: string) { export function getGlobalDBFile(tmpPath: string) {
return fs.readFileSync(join(tmpPath, GLOBAL_DB_EXPORT_FILE), "utf8") return fs.readFileSync(join(tmpPath, GLOBAL_DB_EXPORT_FILE), "utf8")
} }
@ -143,6 +160,9 @@ export async function importApp(
template.file && fs.lstatSync(template.file.path).isDirectory() template.file && fs.lstatSync(template.file.path).isDirectory()
if (template.file && (isTar || isDirectory)) { if (template.file && (isTar || isDirectory)) {
const tmpPath = isTar ? untarFile(template.file) : template.file.path const tmpPath = isTar ? untarFile(template.file) : template.file.path
if (isTar && template.file.password) {
await decryptFiles(tmpPath, template.file.password)
}
const contents = fs.readdirSync(tmpPath) const contents = fs.readdirSync(tmpPath)
// have to handle object import // have to handle object import
if (contents.length) { if (contents.length) {

View File

@ -135,7 +135,7 @@ export function mergeConfigs(update: Datasource, old: Datasource) {
// specific to REST datasources, fix the auth configs again if required // specific to REST datasources, fix the auth configs again if required
if (hasAuthConfigs(update)) { if (hasAuthConfigs(update)) {
const configs = update.config.authConfigs as RestAuthConfig[] const configs = update.config.authConfigs as RestAuthConfig[]
const oldConfigs = old.config?.authConfigs as RestAuthConfig[] const oldConfigs = (old.config?.authConfigs as RestAuthConfig[]) || []
for (let config of configs) { for (let config of configs) {
if (config.type !== RestAuthType.BASIC) { if (config.type !== RestAuthType.BASIC) {
continue continue

View File

@ -10,7 +10,7 @@
}, },
"scripts": { "scripts": {
"setup": "yarn && node scripts/createEnv.js", "setup": "yarn && node scripts/createEnv.js",
"test": "jest --runInBand --json --outputFile=testResults.json", "test": "jest --runInBand --json --outputFile=testResults.json --forceExit",
"test:watch": "yarn run test --watch", "test:watch": "yarn run test --watch",
"test:debug": "DEBUG=1 yarn run test", "test:debug": "DEBUG=1 yarn run test",
"test:notify": "node scripts/testResultsWebhook", "test:notify": "node scripts/testResultsWebhook",

1269
yarn.lock

File diff suppressed because it is too large Load Diff