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

@ -25,50 +25,17 @@ jobs:
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,16 +339,18 @@
</Tab> </Tab>
{/if} {/if}
<div class="drawer-actions"> <div class="drawer-actions">
{#if drawerActions?.hide}
<Button <Button
secondary secondary
quiet quiet
on:click={() => { on:click={() => {
store.actions.settings.propertyFocus(null)
drawerActions.hide() drawerActions.hide()
}} }}
> >
Cancel Cancel
</Button> </Button>
{/if}
{#if bindingDrawerActions?.save}
<Button <Button
cta cta
disabled={!valid} disabled={!valid}
@ -358,6 +360,7 @@
> >
Save Save
</Button> </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
title={stepConfig[currentStep].title}
confirmText={stepConfig[currentStep].confirmText}
onConfirm={stepConfig[currentStep].onConfirm}
disabled={!stepConfig[currentStep].isValid}
>
{#if currentStep === Step.CONFIG}
<Body>
<Toggle
text="Export rows from internal tables"
bind:value={includeInternalTablesRows}
/>
<Toggle text="Encrypt my export" bind:value={encypt} />
</Body>
{#if !encypt}
<InlineAlert <InlineAlert
header="Do not share your budibase application exports publicly as they may contain sensitive information such as database credentials or secret keys." header="Do not share your budibase application exports publicly as they may contain sensitive information such as database credentials or secret keys."
/> />
<Body {/if}
>Apps can be exported with or without data that is within internal tables - {/if}
select this below.</Body {#if currentStep === Step.SET_PASSWORD}
> <Input
<Toggle text="Exclude Rows" bind:value={excludeRows} /> 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
disabled={!changed || !isValid || loading}
cta
on:click={saveDatasource}
>
<div class="save-button-content">
{#if loading}
<Spinner size="10">Save</Spinner>
{/if}
Save 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