1
0
Fork 0
mirror of synced 2024-07-09 00:06:05 +12:00

Merge branch 'develop' into chore/field_with_large_relationships

This commit is contained in:
Adria Navarro 2023-09-25 21:41:03 +02:00 committed by GitHub
commit ee6548b3fb
41 changed files with 1141 additions and 68 deletions

View file

@ -55,7 +55,7 @@ http {
set $csp_style "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com";
set $csp_object "object-src 'none'";
set $csp_base_uri "base-uri 'self'";
set $csp_connect "connect-src 'self' https://*.budibase.app https://*.budibase.qa https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com";
set $csp_connect "connect-src 'self' https://*.budibase.app https://*.budibaseqa.app https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com";
set $csp_font "font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me https://maxcdn.bootstrapcdn.com https://js.intercomcdn.com https://fonts.intercomcdn.com";
set $csp_frame "frame-src 'self' https:";
set $csp_img "img-src http: https: data: blob:";

View file

@ -1,5 +1,5 @@
{
"version": "2.10.12-alpha.10",
"version": "2.10.12-alpha.13",
"npmClient": "yarn",
"packages": [
"packages/*"

View file

@ -86,8 +86,8 @@ export const useAuditLogs = () => {
return useFeature(Feature.AUDIT_LOGS)
}
export const usePublicApiUserRoles = () => {
return useFeature(Feature.USER_ROLE_PUBLIC_API)
export const useExpandedPublicApi = () => {
return useFeature(Feature.EXPANDED_PUBLIC_API)
}
export const useScimIntegration = () => {

View file

@ -14,13 +14,14 @@
import GridManageAccessButton from "components/backend/DataTable/buttons/grid/GridManageAccessButton.svelte"
import GridRelationshipButton from "components/backend/DataTable/buttons/grid/GridRelationshipButton.svelte"
import GridEditColumnModal from "components/backend/DataTable/modals/grid/GridEditColumnModal.svelte"
import GridUsersTableButton from "components/backend/DataTable/modals/grid/GridUsersTableButton.svelte"
const userSchemaOverrides = {
firstName: { displayName: "First name", disabled: true },
lastName: { displayName: "Last name", disabled: true },
email: { displayName: "Email", disabled: true },
roleId: { displayName: "Role", disabled: true },
status: { displayName: "Status", disabled: true },
firstName: { displayName: "First name" },
lastName: { displayName: "Last name" },
email: { displayName: "Email" },
roleId: { displayName: "Role" },
status: { displayName: "Status" },
}
$: id = $tables.selected?._id
@ -59,22 +60,22 @@
datasource={gridDatasource}
canAddRows={!isUsersTable}
canDeleteRows={!isUsersTable}
canEditRows={!isUsersTable}
canEditColumns={!isUsersTable}
schemaOverrides={isUsersTable ? userSchemaOverrides : null}
showAvatars={false}
on:updatedatasource={handleGridTableUpdate}
>
<svelte:fragment slot="filter">
{#if isUsersTable}
<GridUsersTableButton />
{/if}
<GridFilterButton />
</svelte:fragment>
<svelte:fragment slot="edit-column">
<GridEditColumnModal />
</svelte:fragment>
<svelte:fragment slot="add-column">
<GridAddColumnModal />
</svelte:fragment>
<svelte:fragment slot="controls">
<GridCreateViewButton />
{#if !isUsersTable}
<GridCreateViewButton />
{/if}
<GridManageAccessButton />
{#if relationshipsEnabled}
<GridRelationshipButton />
@ -84,7 +85,6 @@
{:else}
<GridImportButton />
{/if}
<GridExportButton />
{#if isUsersTable}
<GridEditUserModal />
@ -92,6 +92,12 @@
<GridCreateEditRowModal />
{/if}
</svelte:fragment>
<svelte:fragment slot="edit-column">
<GridEditColumnModal />
</svelte:fragment>
<svelte:fragment slot="add-column">
<GridAddColumnModal />
</svelte:fragment>
</Grid>
</div>

View file

@ -0,0 +1,54 @@
<script>
import { ActionButton, Popover, Heading, Body, Button } from "@budibase/bbui"
import { store } from "builderStore"
let anchor
let open = false
const openSidePanel = () => {
store.update(state => ({
...state,
builderSidePanel: true,
}))
open = false
}
</script>
<div bind:this={anchor}>
<ActionButton on:click={() => (open = true)} icon="Help" quiet>
Why can't I edit this table?
</ActionButton>
</div>
<Popover bind:open {anchor} align="left">
<div class="content">
<Heading size="XS">The app users table is read only</Heading>
<Body size="S">
You can continue to view the users that have access to your application.
</Body>
<Body size="S">
Manage and invite more application users using the user side panel in the
top right of your screen.
</Body>
<div class="button">
<Button cta on:click={openSidePanel}>Open users panel</Button>
</div>
</div>
</Popover>
<style>
.content {
width: 300px;
padding: var(--spacing-l);
display: flex;
flex-direction: column;
gap: var(--spacing-l);
}
.content :global(.spectrum-Heading) {
font-weight: 400;
}
.button {
display: flex;
justify-content: flex-end;
}
</style>

View file

@ -180,7 +180,7 @@
<div class="hierarchy-items-container">
<NavItem
icon="UserGroup"
text="Users"
text="App users"
selected={$isActive("./table/:tableId") &&
$tables.selected?._id === TableNames.USERS}
on:click={() => selectTable(TableNames.USERS)}

View file

@ -0,0 +1,69 @@
<script>
import {
ModalContent,
Toggle,
Input,
Layout,
Dropzone,
notifications,
Body,
} from "@budibase/bbui"
import { API } from "api"
import { automationStore, store } from "../../builderStore"
export let app
$: disabled = (encrypted && !password) || !file
let encrypted = false,
password
let file
async function updateApp() {
try {
let data = new FormData()
data.append("appExport", file)
if (encrypted) {
data.append("encryptionPassword", password.trim())
}
const appId = app.devId
await API.updateAppFromExport(appId, data)
const pkg = await API.fetchAppPackage(appId)
await store.actions.initialise(pkg)
await automationStore.actions.fetch()
notifications.success("App updated successfully")
} catch (err) {
notifications.error(`Failed to update app - ${err.message || err}`)
}
}
</script>
<ModalContent
title={`Update ${app.name}`}
confirmText="Update"
onConfirm={updateApp}
bind:disabled
>
<Body size="S"
>Updating an app using an app export will replace all tables, datasources,
queries, screens and automations. It is recommended to perform a backup
before running this operation.</Body
>
<Layout noPadding gap="XS">
<Dropzone
gallery={false}
label="App export"
on:change={e => {
file = e.detail?.[0]
}}
/>
<Toggle text="Encrypted" bind:value={encrypted} />
{#if encrypted}
<Input
type="password"
label="Password"
placeholder="Type here..."
bind:value={password}
/>
{/if}
</Layout>
</ModalContent>

View file

@ -32,9 +32,9 @@
active={$isActive("./embed")}
/>
<SideNavItem
text="Export"
url={$url("./export")}
active={$isActive("./export")}
text="Export/Import"
url={$url("./exportImport")}
active={$isActive("./exportImport")}
/>
<SideNavItem
text="Name and URL"

View file

@ -11,31 +11,39 @@
import { apps } from "stores/portal"
import { store } from "builderStore"
import ExportAppModal from "components/start/ExportAppModal.svelte"
import ImportAppModal from "components/start/ImportAppModal.svelte"
$: filteredApps = $apps.filter(app => app.devId == $store.appId)
$: app = filteredApps.length ? filteredApps[0] : {}
$: appDeployed = app?.status === AppStatus.DEPLOYED
let exportModal
let exportModal, importModal
let exportPublishedVersion = false
const exportApp = opts => {
exportPublishedVersion = !!opts?.published
exportModal.show()
}
const importApp = () => {
importModal.show()
}
</script>
<Modal bind:this={exportModal} padding={false}>
<ExportAppModal {app} published={exportPublishedVersion} />
</Modal>
<Modal bind:this={importModal} padding={false}>
<ImportAppModal {app} />
</Modal>
<Layout noPadding>
<Layout gap="XS" noPadding>
<Heading>Export your app</Heading>
<Body>Export your latest edited or published app</Body>
</Layout>
<Divider />
<div class="export-body">
<div class="body">
<ActionButton secondary on:click={() => exportApp({ published: false })}>
Export latest edited app
</ActionButton>
@ -47,10 +55,20 @@
Export latest published app
</ActionButton>
</div>
<Divider />
<Layout gap="XS" noPadding>
<Heading>Import your app</Heading>
<Body>Import an export to update this app</Body>
</Layout>
<div class="body">
<ActionButton secondary on:click={() => importApp()}>
Import app
</ActionButton>
</div>
</Layout>
<style>
.export-body {
.body {
display: flex;
gap: var(--spacing-l);
}

View file

@ -101,7 +101,7 @@
justify-content: flex-start;
align-items: center;
border-bottom: var(--border-light);
padding: 0 24px;
padding: 0 var(--spacing-l);
gap: 24px;
position: relative;
}

View file

@ -0,0 +1,15 @@
<script>
import { params, redirect } from "@roxi/routify"
import { apps } from "stores/portal"
$: app = $apps.find(app => app.appId === $params.appId)
$: {
if (!app) {
$redirect("../")
}
}
</script>
{#if app}
<slot />
{/if}

View file

@ -0,0 +1,79 @@
<script>
import { params, goto } from "@roxi/routify"
import { apps, auth, sideBarCollapsed } from "stores/portal"
import { ActionButton } from "@budibase/bbui"
import { sdk } from "@budibase/shared-core"
$: app = $apps.find(app => app.appId === $params.appId)
$: iframeUrl = getIframeURL(app)
$: isBuilder = sdk.users.isBuilder($auth.user, app?.devId)
const getIframeURL = app => {
if (app.status === "published") {
return `/app${app.url}`
}
return `/${app.devId}`
}
</script>
<div class="container">
<div class="header">
{#if $sideBarCollapsed}
<ActionButton
quiet
icon="Rail"
on:click={() => sideBarCollapsed.set(false)}
>
Menu
</ActionButton>
{:else}
<ActionButton
quiet
icon="RailRightOpen"
on:click={() => sideBarCollapsed.set(true)}
>
Collapse
</ActionButton>
{/if}
{#if isBuilder}
<ActionButton
quiet
icon="Edit"
on:click={() => $goto(`../../app/${app.devId}`)}
>
Edit
</ActionButton>
{/if}
<ActionButton
quiet
icon="LinkOut"
on:click={() => window.open(iframeUrl, "_blank")}
>
Fullscreen
</ActionButton>
</div>
<iframe src={iframeUrl} title={app.name} />
</div>
<style>
.container {
flex: 1 1 auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
padding: 0 var(--spacing-l) var(--spacing-l) var(--spacing-l);
}
.header {
display: flex;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-xs);
flex: 0 0 50px;
}
iframe {
flex: 1 1 auto;
border-radius: var(--spacing-s);
border: 1px solid var(--spectrum-global-color-gray-300);
}
</style>

View file

@ -0,0 +1,140 @@
<script>
import { Icon, Body } from "@budibase/bbui"
import { apps, sideBarCollapsed } from "stores/portal"
import { params, goto } from "@roxi/routify"
import { tick } from "svelte"
import NavItem from "components/common/NavItem.svelte"
let searchInput
let searchString
let searching = false
$: filteredApps = $apps.filter(app => {
return (
!searchString ||
app.name.toLowerCase().includes(searchString.toLowerCase())
)
})
const startSearching = async () => {
searching = true
searchString = ""
await tick()
searchInput.focus()
}
const stopSearching = () => {
searching = false
searchString = ""
}
</script>
<div class="side-bar" class:collapsed={$sideBarCollapsed}>
<div class="side-bar-controls">
{#if searching}
<input
bind:this={searchInput}
bind:value={searchString}
placeholder="Search for apps"
/>
{:else}
<Body size="S">Apps</Body>
<Icon name="Search" size="S" hoverable on:click={startSearching} />
{/if}
<div class="rotational" class:rotated={searching}>
<Icon
name="Add"
hoverable
on:click={searching ? stopSearching : () => $goto("./create")}
/>
</div>
</div>
<div class="side-bar-nav">
<NavItem
icon="WebPages"
text="All apps"
on:click={() => $goto("./")}
selected={!$params.appId}
/>
{#each filteredApps as app}
<NavItem
text={app.name}
icon={app.icon?.name || "Apps"}
iconColor={app.icon?.color}
selected={$params.appId === app.appId}
on:click={() => $goto(`./${app.appId}`)}
/>
{/each}
</div>
</div>
<style>
.side-bar {
flex: 0 0 260px;
display: flex;
flex-direction: column;
align-items: stretch;
border-right: var(--border-light);
background: var(--spectrum-global-color-gray-100);
overflow: hidden;
transition: margin-left 300ms ease-out;
}
.side-bar.collapsed {
margin-left: -262px;
}
@media (max-width: 640px) {
.side-bar {
margin-left: -262px;
}
}
.side-bar-controls {
flex: 0 0 50px;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-l);
padding: 0 var(--spacing-l);
}
.side-bar-controls :global(.spectrum-Body),
.side-bar-controls input {
flex: 1 1 auto;
}
.side-bar-controls :global(.spectrum-Icon) {
color: var(--spectrum-global-color-gray-700);
}
input {
outline: none;
border: none;
max-width: none;
flex: 1 1 auto;
color: var(--spectrum-global-color-gray-800);
font-size: 14px;
padding: 0;
transition: border 130ms ease-out;
font-family: var(--font-sans);
background: inherit;
}
input::placeholder {
color: var(--spectrum-global-color-gray-700);
transition: color 130ms ease-out;
}
input:hover::placeholder {
color: var(--spectrum-global-color-gray-800);
}
.side-bar-nav {
flex: 1 1 auto;
overflow: auto;
overflow-x: hidden;
}
div.rotational {
transition: transform 130ms ease-out;
}
div.rotational.rotated {
transform: rotate(45deg);
}
</style>

View file

@ -11,6 +11,7 @@
import { onMount } from "svelte"
import { redirect } from "@roxi/routify"
import { sdk } from "@budibase/shared-core"
import PortalSideBar from "./_components/PortalSideBar.svelte"
// Don't block loading if we've already hydrated state
let loaded = $apps.length != null
@ -44,5 +45,18 @@
</script>
{#if loaded}
<slot />
<div class="page">
<PortalSideBar />
<slot />
</div>
{/if}
<style>
.page {
flex: 1 1 auto;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: stretch;
}
</style>

View file

@ -1,3 +1,5 @@
import { writable } from "svelte/store"
export { organisation } from "./organisation"
export { users } from "./users"
export { admin } from "./admin"
@ -14,3 +16,5 @@ export { environment } from "./environment"
export { menu } from "./menu"
export { auditLogs } from "./auditLogs"
export { features } from "./features"
export const sideBarCollapsed = writable(false)

View file

@ -1,3 +1,5 @@
import { sdk } from "@budibase/shared-core"
export const buildAppEndpoints = API => ({
/**
* Fetches screen definition for an app.
@ -81,6 +83,22 @@ export const buildAppEndpoints = API => ({
})
},
/**
* Update an application using an export - the body
* should be of type FormData, with a "file" and a "password" if encrypted.
* @param appId The ID of the app to update - this will always be
* converted to development ID.
* @param body a FormData body with a file and password.
*/
updateAppFromExport: async (appId, body) => {
const devId = sdk.applications.getDevAppID(appId)
return await API.post({
url: `/api/applications/${devId}/import`,
body,
json: false,
})
},
/**
* Imports an export of all apps.
* @param apps the FormData containing the apps to import

@ -1 +1 @@
Subproject commit 3c51e0938e2226038f4456bb8c96d857310b8d0c
Subproject commit 30385682141e5ba9d98de7d71d5be1672109cd15

View file

@ -9,6 +9,7 @@ CREATE TABLE Persons (
Address varchar(255),
City varchar(255) DEFAULT 'Belfast',
Age INTEGER DEFAULT 20 NOT NULL,
Year INTEGER,
Type person_job
);
CREATE TABLE Tasks (
@ -49,9 +50,10 @@ CREATE TABLE CompositeTable (
Name varchar(255),
PRIMARY KEY (KeyPartOne, KeyPartTwo)
);
INSERT INTO Persons (FirstName, LastName, Address, City, Type) VALUES ('Mike', 'Hughes', '123 Fake Street', 'Belfast', 'qa');
INSERT INTO Persons (FirstName, LastName, Address, City, Type) VALUES ('John', 'Smith', '64 Updown Road', 'Dublin', 'programmer');
INSERT INTO Persons (FirstName, LastName, Address, City, Type, Age) VALUES ('Foo', 'Bar', 'Foo Street', 'Bartown', 'support', 0);
INSERT INTO Persons (FirstName, LastName, Address, City, Type, Year) VALUES ('Mike', 'Hughes', '123 Fake Street', 'Belfast', 'qa', 1999);
INSERT INTO Persons (FirstName, LastName, Address, City, Type, Year) VALUES ('John', 'Smith', '64 Updown Road', 'Dublin', 'programmer', 1996);
INSERT INTO Persons (FirstName, LastName, Address, City, Type, Age, Year) VALUES ('Foo', 'Bar', 'Foo Street', 'Bartown', 'support', 0, 1993);
INSERT INTO Persons (FirstName, LastName, Address, City, Type) VALUES ('Jonny', 'Muffin', 'Muffin Street', 'Cork', 'support');
INSERT INTO Tasks (ExecutorID, QaID, TaskName, Completed) VALUES (1, 2, 'assembling', TRUE);
INSERT INTO Tasks (ExecutorID, QaID, TaskName, Completed) VALUES (2, 1, 'processing', FALSE);
INSERT INTO Products (ProductName) VALUES ('Computers');

View file

@ -613,6 +613,23 @@
"data"
]
},
"appExport": {
"type": "object",
"properties": {
"encryptPassword": {
"description": "An optional password used to encrypt the export.",
"type": "string"
},
"excludeRows": {
"description": "Set whether the internal table rows should be excluded from the export.",
"type": "boolean"
}
},
"required": [
"encryptPassword",
"excludeRows"
]
},
"row": {
"description": "The row to be created/updated, based on the table schema.",
"type": "object",
@ -2163,6 +2180,87 @@
}
}
},
"/applications/{appId}/import": {
"post": {
"operationId": "appImport",
"summary": "Import an app to an existing app 🔒",
"description": "This endpoint is only available on a business or enterprise license.",
"tags": [
"applications"
],
"parameters": [
{
"$ref": "#/components/parameters/appIdUrl"
}
],
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"properties": {
"encryptedPassword": {
"description": "Password for the export if it is encrypted.",
"type": "string"
},
"appExport": {
"description": "The app export to import.",
"type": "string",
"format": "binary"
}
},
"required": [
"appExport"
]
}
}
}
},
"responses": {
"204": {
"description": "Application has been updated."
}
}
}
},
"/applications/{appId}/export": {
"post": {
"operationId": "appExport",
"summary": "Export an app 🔒",
"description": "This endpoint is only available on a business or enterprise license.",
"tags": [
"applications"
],
"parameters": [
{
"$ref": "#/components/parameters/appIdUrl"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/appExport"
}
}
}
},
"responses": {
"200": {
"description": "A gzip tarball containing the app export, encrypted if password provided.",
"content": {
"application/gzip": {
"schema": {
"type": "string",
"format": "binary",
"example": "Tarball containing database and object store contents..."
}
}
}
}
}
}
},
"/applications/search": {
"post": {
"operationId": "appSearch",

View file

@ -587,6 +587,19 @@ components:
- appUrl
required:
- data
appExport:
type: object
properties:
encryptPassword:
description: An optional password used to encrypt the export.
type: string
excludeRows:
description: Set whether the internal table rows should be excluded from the
export.
type: boolean
required:
- encryptPassword
- excludeRows
row:
description: The row to be created/updated, based on the table schema.
type: object
@ -1763,6 +1776,57 @@ paths:
examples:
deployment:
$ref: "#/components/examples/deploymentOutput"
"/applications/{appId}/import":
post:
operationId: appImport
summary: Import an app to an existing app 🔒
description: This endpoint is only available on a business or enterprise license.
tags:
- applications
parameters:
- $ref: "#/components/parameters/appIdUrl"
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
encryptedPassword:
description: Password for the export if it is encrypted.
type: string
appExport:
description: The app export to import.
type: string
format: binary
required:
- appExport
responses:
"204":
description: Application has been updated.
"/applications/{appId}/export":
post:
operationId: appExport
summary: Export an app 🔒
description: This endpoint is only available on a business or enterprise license.
tags:
- applications
parameters:
- $ref: "#/components/parameters/appIdUrl"
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/appExport"
responses:
"200":
description: A gzip tarball containing the app export, encrypted if password
provided.
content:
application/gzip:
schema:
type: string
format: binary
example: Tarball containing database and object store contents...
/applications/search:
post:
operationId: appSearch

View file

@ -134,4 +134,15 @@ export default new Resource()
deploymentOutput: object({
data: deploymentOutputSchema,
}),
appExport: object({
encryptPassword: {
description: "An optional password used to encrypt the export.",
type: "string",
},
excludeRows: {
description:
"Set whether the internal table rows should be excluded from the export.",
type: "boolean",
},
}),
})

View file

@ -39,9 +39,8 @@ import {
} from "../../db/defaultData/datasource_bb_default"
import { removeAppFromUserRoles } from "../../utilities/workerRequests"
import { stringToReadStream } from "../../utilities"
import { doesUserHaveLock, getLocksById } from "../../utilities/redis"
import { doesUserHaveLock } from "../../utilities/redis"
import { cleanupAutomations } from "../../automations/utils"
import { checkAppMetadata } from "../../automations/logging"
import { getUniqueRows } from "../../utilities/usageQuota/rows"
import { groups, licensing, quotas } from "@budibase/pro"
import {
@ -51,7 +50,6 @@ import {
PlanType,
Screen,
UserCtx,
ContextUser,
} from "@budibase/types"
import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts"
import sdk from "../../sdk"
@ -575,6 +573,28 @@ export async function sync(ctx: UserCtx) {
}
}
export async function importToApp(ctx: UserCtx) {
const { appId } = ctx.params
const appExport = ctx.request.files?.appExport
const password = ctx.request.body.encryptionPassword as string
if (!appExport) {
ctx.throw(400, "Must supply app export to import")
}
if (Array.isArray(appExport)) {
ctx.throw(400, "Must only supply one app export")
}
const fileAttributes = { type: appExport.type!, path: appExport.path! }
try {
await sdk.applications.updateWithExport(appId, fileAttributes, password)
} catch (err: any) {
ctx.throw(
500,
`Unable to perform update, please retry - ${err?.message || err}`
)
}
ctx.body = { message: "app updated" }
}
export async function updateAppPackage(appPackage: any, appId: any) {
return context.doInAppContext(appId, async () => {
const db = context.getAppDB()

View file

@ -2,9 +2,11 @@ import { db as dbCore, context } from "@budibase/backend-core"
import { search as stringSearch, addRev } from "./utils"
import * as controller from "../application"
import * as deployController from "../deploy"
import * as backupController from "../backup"
import { Application } from "../../../definitions/common"
import { UserCtx } from "@budibase/types"
import { Next } from "koa"
import { sdk as proSdk } from "@budibase/pro"
function fixAppID(app: Application, params: any) {
if (!params) {
@ -80,6 +82,8 @@ export async function destroy(ctx: UserCtx, next: Next) {
export async function unpublish(ctx: UserCtx, next: Next) {
await context.doInAppContext(ctx.params.appId, async () => {
await controller.unpublish(ctx)
ctx.body = undefined
ctx.status = 204
await next()
})
}
@ -91,12 +95,22 @@ export async function publish(ctx: UserCtx, next: Next) {
})
}
// get licensed endpoints from pro
export const importToApp = proSdk.publicApi.applications.buildImportFn(
controller.importToApp
)
export const exportApp = proSdk.publicApi.applications.buildExportFn(
backupController.exportAppDump
)
export default {
create,
update,
read,
destroy,
search,
publish,
unpublish,
publish,
importToApp,
exportApp,
}

View file

@ -4,6 +4,7 @@ import * as deploymentController from "../controllers/deploy"
import authorized from "../../middleware/authorized"
import { permissions } from "@budibase/backend-core"
import { applicationValidator } from "./utils/validators"
import { importToApp } from "../controllers/application"
const router: Router = new Router()
@ -58,5 +59,10 @@ router
authorized(permissions.GLOBAL_BUILDER),
controller.destroy
)
.post(
"/api/applications/:appId/import",
authorized(permissions.BUILDER),
controller.importToApp
)
export default router

View file

@ -137,6 +137,70 @@ write.push(
new Endpoint("post", "/applications/:appId/publish", controller.publish)
)
/**
* @openapi
* /applications/{appId}/import:
* post:
* operationId: appImport
* summary: Import an app to an existing app 🔒
* description: This endpoint is only available on a business or enterprise license.
* tags:
* - applications
* parameters:
* - $ref: '#/components/parameters/appIdUrl'
* requestBody:
* content:
* multipart/form-data:
* schema:
* type: object
* properties:
* encryptedPassword:
* description: Password for the export if it is encrypted.
* type: string
* appExport:
* description: The app export to import.
* type: string
* format: binary
* required:
* - appExport
* responses:
* 204:
* description: Application has been updated.
*/
write.push(
new Endpoint("post", "/applications/:appId/import", controller.importToApp)
)
/**
* @openapi
* /applications/{appId}/export:
* post:
* operationId: appExport
* summary: Export an app 🔒
* description: This endpoint is only available on a business or enterprise license.
* tags:
* - applications
* parameters:
* - $ref: '#/components/parameters/appIdUrl'
* requestBody:
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/appExport'
* responses:
* 200:
* description: A gzip tarball containing the app export, encrypted if password provided.
* content:
* application/gzip:
* schema:
* type: string
* format: binary
* example: Tarball containing database and object store contents...
*/
read.push(
new Endpoint("post", "/applications/:appId/export", controller.exportApp)
)
/**
* @openapi
* /applications/{appId}:

View file

@ -1,3 +1,4 @@
import { Ctx } from "@budibase/types"
import mapping from "../../../controllers/public/mapping"
enum Resources {
@ -9,11 +10,19 @@ enum Resources {
SEARCH = "search",
}
function isArrayResponse(ctx: any) {
function isAttachment(ctx: Ctx) {
return ctx.body?.path && ctx.body?.flags && ctx.body?.mode
}
function isArrayResponse(ctx: Ctx) {
return ctx.url.endsWith(Resources.SEARCH) || Array.isArray(ctx.body)
}
function processApplications(ctx: any) {
function noResponse(ctx: Ctx) {
return !Array.isArray(ctx.body) && Object.keys(ctx.body).length === 0
}
function processApplications(ctx: Ctx) {
if (isArrayResponse(ctx)) {
return mapping.mapApplications(ctx)
} else {
@ -21,7 +30,7 @@ function processApplications(ctx: any) {
}
}
function processTables(ctx: any) {
function processTables(ctx: Ctx) {
if (isArrayResponse(ctx)) {
return mapping.mapTables(ctx)
} else {
@ -29,7 +38,7 @@ function processTables(ctx: any) {
}
}
function processRows(ctx: any) {
function processRows(ctx: Ctx) {
if (isArrayResponse(ctx)) {
return mapping.mapRowSearch(ctx)
} else {
@ -37,7 +46,7 @@ function processRows(ctx: any) {
}
}
function processUsers(ctx: any) {
function processUsers(ctx: Ctx) {
if (isArrayResponse(ctx)) {
return mapping.mapUsers(ctx)
} else {
@ -45,7 +54,7 @@ function processUsers(ctx: any) {
}
}
function processQueries(ctx: any) {
function processQueries(ctx: Ctx) {
if (isArrayResponse(ctx)) {
return mapping.mapQueries(ctx)
} else {
@ -53,8 +62,8 @@ function processQueries(ctx: any) {
}
}
export default async (ctx: any, next: any) => {
if (!ctx.body) {
export default async (ctx: Ctx, next: any) => {
if (!ctx.body || noResponse(ctx) || isAttachment(ctx)) {
return await next()
}
let urlParts = ctx.url.split("/")

View file

@ -0,0 +1,91 @@
import * as setup from "../../tests/utilities"
import {
generateMakeRequest,
generateMakeRequestWithFormData,
MakeRequestResponse,
MakeRequestWithFormDataResponse,
} from "./utils"
import { User } from "@budibase/types"
import { join } from "path"
import { mocks } from "@budibase/backend-core/tests"
const PASSWORD = "testtest"
const NO_LICENSE_MSG = "Endpoint unavailable, license required."
let config = setup.getConfig()
let apiKey: string,
globalUser: User,
makeRequest: MakeRequestResponse,
makeRequestFormData: MakeRequestWithFormDataResponse
beforeAll(async () => {
await config.init()
globalUser = await config.globalUser()
apiKey = await config.generateApiKey(globalUser._id)
makeRequest = generateMakeRequest(apiKey)
makeRequestFormData = generateMakeRequestWithFormData(apiKey)
})
afterAll(setup.afterAll)
describe("check export/import", () => {
async function runExport() {
return await makeRequest("post", `/applications/${config.appId}/export`, {
encryptionPassword: PASSWORD,
excludeRows: true,
})
}
async function runImport() {
const pathToExport = join(
__dirname,
"..",
"..",
"tests",
"assets",
"export.tar.gz"
)
return await makeRequestFormData(
"post",
`/applications/${config.appId}/import`,
{
encryptionPassword: PASSWORD,
appExport: { path: pathToExport },
}
)
}
it("check licensing for export", async () => {
const res = await runExport()
expect(res.status).toBe(403)
expect(res.body.message).toBe(NO_LICENSE_MSG)
})
it("check licensing for import", async () => {
const res = await runImport()
expect(res.status).toBe(403)
expect(res.body.message).toBe(NO_LICENSE_MSG)
})
it("should be able to export app", async () => {
mocks.licenses.useExpandedPublicApi()
const res = await runExport()
expect(res.headers["content-disposition"]).toMatch(
/attachment; filename=".*-export-.*\.tar.gz"/g
)
expect(res.body instanceof Buffer).toBe(true)
expect(res.status).toBe(200)
})
it("should be able to import app", async () => {
mocks.licenses.useExpandedPublicApi()
const res = await runImport()
expect(Object.keys(res.body).length).toBe(0)
// check screens imported correctly
const screens = await config.api.screen.list()
expect(screens.length).toBe(2)
expect(screens[0].routing.route).toBe("/derp")
expect(screens[1].routing.route).toBe("/blank")
expect(res.status).toBe(204)
})
})

View file

@ -92,7 +92,7 @@ describe("no user role update in free", () => {
describe("no user role update in business", () => {
beforeAll(() => {
updateMock()
mocks.licenses.usePublicApiUserRoles()
mocks.licenses.useExpandedPublicApi()
})
it("should allow 'roles' to be updated", async () => {
@ -105,7 +105,7 @@ describe("no user role update in business", () => {
})
it("should allow 'admin' to be updated", async () => {
mocks.licenses.usePublicApiUserRoles()
mocks.licenses.useExpandedPublicApi()
const res = await makeRequest("post", "/users", {
...base(),
admin: { global: true },
@ -115,7 +115,7 @@ describe("no user role update in business", () => {
})
it("should allow 'builder' to be updated", async () => {
mocks.licenses.usePublicApiUserRoles()
mocks.licenses.useExpandedPublicApi()
const res = await makeRequest("post", "/users", {
...base(),
builder: { global: true },

View file

@ -11,6 +11,32 @@ export type MakeRequestResponse = (
intAppId?: string
) => Promise<supertest.Response>
export type MakeRequestWithFormDataResponse = (
method: HttpMethod,
endpoint: string,
fields: Record<string, string | { path: string }>,
intAppId?: string
) => Promise<supertest.Response>
function base(
apiKey: string,
endpoint: string,
intAppId: string | null,
isInternal: boolean
) {
const extraHeaders: any = {
"x-budibase-api-key": apiKey,
}
if (intAppId) {
extraHeaders["x-budibase-app-id"] = intAppId
}
const url = isInternal
? endpoint
: checkSlashesInUrl(`/api/public/v1/${endpoint}`)
return { headers: extraHeaders, url }
}
export function generateMakeRequest(
apiKey: string,
isInternal = false
@ -23,18 +49,8 @@ export function generateMakeRequest(
body?: any,
intAppId: string | null = config.getAppId()
) => {
const extraHeaders: any = {
"x-budibase-api-key": apiKey,
}
if (intAppId) {
extraHeaders["x-budibase-app-id"] = intAppId
}
const url = isInternal
? endpoint
: checkSlashesInUrl(`/api/public/v1/${endpoint}`)
const req = request[method](url).set(config.defaultHeaders(extraHeaders))
const { headers, url } = base(apiKey, endpoint, intAppId, isInternal)
const req = request[method](url).set(config.defaultHeaders(headers))
if (body) {
req.send(body)
}
@ -43,3 +59,30 @@ export function generateMakeRequest(
return res
}
}
export function generateMakeRequestWithFormData(
apiKey: string,
isInternal = false
): MakeRequestWithFormDataResponse {
const request = setup.getRequest()!
const config = setup.getConfig()!
return async (
method: HttpMethod,
endpoint: string,
fields: Record<string, string | { path: string }>,
intAppId: string | null = config.getAppId()
) => {
const { headers, url } = base(apiKey, endpoint, intAppId, isInternal)
const req = request[method](url).set(config.defaultHeaders(headers))
for (let [field, value] of Object.entries(fields)) {
if (typeof value === "string") {
req.field(field, value)
} else {
req.attach(field, value.path)
}
}
const res = await req
expect(res.body).toBeDefined()
return res
}
}

View file

@ -0,0 +1,32 @@
import * as setup from "./utilities"
import path from "path"
jest.setTimeout(15000)
const PASSWORD = "testtest"
describe("/applications/:appId/import", () => {
let request = setup.getRequest()
let config = setup.getConfig()
afterAll(setup.afterAll)
beforeAll(async () => {
await config.init()
})
it("should be able to perform import", async () => {
const appId = config.getAppId()
const res = await request
.post(`/api/applications/${appId}/import`)
.field("encryptionPassword", PASSWORD)
.attach("appExport", path.join(__dirname, "assets", "export.tar.gz"))
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.message).toBe("app updated")
const screens = await config.api.screen.list()
expect(screens.length).toBe(2)
expect(screens[0].routing.route).toBe("/derp")
expect(screens[1].routing.route).toBe("/blank")
})
})

View file

@ -18,6 +18,14 @@ export interface paths {
"/applications/{appId}/publish": {
post: operations["appPublish"];
};
"/applications/{appId}/import": {
/** This endpoint is only available on a business or enterprise license. */
post: operations["appImport"];
};
"/applications/{appId}/export": {
/** This endpoint is only available on a business or enterprise license. */
post: operations["appExport"];
};
"/applications/search": {
/** Based on application properties (currently only name) search for applications. */
post: operations["appSearch"];
@ -158,6 +166,12 @@ export interface components {
appUrl: string;
};
};
appExport: {
/** @description An optional password used to encrypt the export. */
encryptPassword: string;
/** @description Set whether the internal table rows should be excluded from the export. */
excludeRows: boolean;
};
/** @description The row to be created/updated, based on the table schema. */
row: { [key: string]: unknown };
searchOutput: {
@ -889,6 +903,54 @@ export interface operations {
};
};
};
/** This endpoint is only available on a business or enterprise license. */
appImport: {
parameters: {
path: {
/** The ID of the app which this request is targeting. */
appId: components["parameters"]["appIdUrl"];
};
};
responses: {
/** Application has been updated. */
204: never;
};
requestBody: {
content: {
"multipart/form-data": {
/** @description Password for the export if it is encrypted. */
encryptedPassword?: string;
/**
* Format: binary
* @description The app export to import.
*/
appExport: string;
};
};
};
};
/** This endpoint is only available on a business or enterprise license. */
appExport: {
parameters: {
path: {
/** The ID of the app which this request is targeting. */
appId: components["parameters"]["appIdUrl"];
};
};
responses: {
/** A gzip tarball containing the app export, encrypted if password provided. */
200: {
content: {
"application/gzip": string;
};
};
};
requestBody: {
content: {
"application/json": components["schemas"]["appExport"];
};
};
};
/** Based on application properties (currently only name) search for applications. */
appSearch: {
responses: {

View file

@ -0,0 +1,102 @@
import { db as dbCore } from "@budibase/backend-core"
import {
DocumentTypesToImport,
Document,
Database,
RowValue,
} from "@budibase/types"
import backups from "../backups"
export type FileAttributes = {
type: string
path: string
}
function mergeUpdateAndDeleteDocuments(
updateDocs: Document[],
deleteDocs: Document[]
) {
// compress the documents to create and to delete (if same ID, then just update the rev)
const finalToDelete = []
for (let deleteDoc of deleteDocs) {
const found = updateDocs.find(doc => doc._id === deleteDoc._id)
if (found) {
found._rev = deleteDoc._rev
} else {
finalToDelete.push(deleteDoc)
}
}
return [...updateDocs, ...finalToDelete]
}
async function removeImportableDocuments(db: Database) {
// get the references to the documents, not the whole document
const docPromises = []
for (let docType of DocumentTypesToImport) {
docPromises.push(db.allDocs(dbCore.getDocParams(docType)))
}
let documentRefs: { _id: string; _rev: string }[] = []
for (let response of await Promise.all(docPromises)) {
documentRefs = documentRefs.concat(
response.rows.map(row => ({
_id: row.id,
_rev: (row.value as RowValue).rev,
}))
)
}
// add deletion key
return documentRefs.map(ref => ({ _deleted: true, ...ref }))
}
async function getImportableDocuments(db: Database) {
// get the whole document
const docPromises = []
for (let docType of DocumentTypesToImport) {
docPromises.push(
db.allDocs(dbCore.getDocParams(docType, null, { include_docs: true }))
)
}
// map the responses to the document itself
let documents: Document[] = []
for (let response of await Promise.all(docPromises)) {
documents = documents.concat(response.rows.map(row => row.doc))
}
// remove the _rev, stops it being written
documents.forEach(doc => {
delete doc._rev
})
return documents
}
export async function updateWithExport(
appId: string,
file: FileAttributes,
password?: string
) {
const devId = dbCore.getDevAppID(appId)
const tempAppName = `temp_${devId}`
const tempDb = dbCore.getDB(tempAppName)
const appDb = dbCore.getDB(devId)
try {
const template = {
file: {
type: file.type!,
path: file.path!,
password,
},
}
// get a temporary version of the import
// don't need obj store, the existing app already has everything we need
await backups.importApp(devId, tempDb, template, {
importObjStoreContents: false,
})
// get the documents to copy
const toUpdate = await getImportableDocuments(tempDb)
// clear out the old documents
const toDelete = await removeImportableDocuments(appDb)
// now bulk update documents - add new ones, delete old ones and update common ones
await appDb.bulkDocs(mergeUpdateAndDeleteDocuments(toUpdate, toDelete))
} finally {
await tempDb.destroy()
}
}

View file

@ -1,9 +1,11 @@
import * as sync from "./sync"
import * as utils from "./utils"
import * as applications from "./applications"
import * as imports from "./import"
export default {
...sync,
...utils,
...applications,
...imports,
}

View file

@ -8,11 +8,7 @@ import {
TABLE_ROW_PREFIX,
USER_METDATA_PREFIX,
} from "../../../db/utils"
import {
DB_EXPORT_FILE,
GLOBAL_DB_EXPORT_FILE,
STATIC_APP_FILES,
} from "./constants"
import { DB_EXPORT_FILE, STATIC_APP_FILES } from "./constants"
import fs from "fs"
import { join } from "path"
import env from "../../../environment"

View file

@ -151,7 +151,8 @@ export function getListOfAppsInMulti(tmpPath: string) {
export async function importApp(
appId: string,
db: Database,
template: TemplateType
template: TemplateType,
opts: { importObjStoreContents: boolean } = { importObjStoreContents: true }
) {
let prodAppId = dbCore.getProdAppID(appId)
let dbStream: any
@ -165,7 +166,7 @@ export async function importApp(
}
const contents = fs.readdirSync(tmpPath)
// have to handle object import
if (contents.length) {
if (contents.length && opts.importObjStoreContents) {
let promises = []
let excludedFiles = [GLOBAL_DB_EXPORT_FILE, DB_EXPORT_FILE]
for (let filename of contents) {

View file

@ -53,7 +53,6 @@ import {
View,
FieldType,
RelationshipType,
ViewV2,
CreateViewRequest,
} from "@budibase/types"

View file

@ -5,6 +5,7 @@ import { TableAPI } from "./table"
import { ViewV2API } from "./viewV2"
import { DatasourceAPI } from "./datasource"
import { LegacyViewAPI } from "./legacyView"
import { ScreenAPI } from "./screen"
export default class API {
table: TableAPI
@ -13,6 +14,7 @@ export default class API {
row: RowAPI
permission: PermissionAPI
datasource: DatasourceAPI
screen: ScreenAPI
constructor(config: TestConfiguration) {
this.table = new TableAPI(config)
@ -21,5 +23,6 @@ export default class API {
this.row = new RowAPI(config)
this.permission = new PermissionAPI(config)
this.datasource = new DatasourceAPI(config)
this.screen = new ScreenAPI(config)
}
}

View file

@ -0,0 +1,18 @@
import TestConfiguration from "../TestConfiguration"
import { Screen } from "@budibase/types"
import { TestAPI } from "./base"
export class ScreenAPI extends TestAPI {
constructor(config: TestConfiguration) {
super(config)
}
list = async (): Promise<Screen[]> => {
const res = await this.request
.get(`/api/screens`)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
return res.body as Screen[]
}
}

View file

@ -39,6 +39,25 @@ export enum DocumentType {
AUDIT_LOG = "al",
}
// these are the core documents that make up the data, design
// and automation sections of an app. This excludes any internal
// rows as we shouldn't import data.
export const DocumentTypesToImport: DocumentType[] = [
DocumentType.ROLE,
DocumentType.DATASOURCE,
DocumentType.DATASOURCE_PLUS,
DocumentType.TABLE,
DocumentType.AUTOMATION,
DocumentType.WEBHOOK,
DocumentType.SCREEN,
DocumentType.QUERY,
DocumentType.METADATA,
DocumentType.MEM_VIEW,
// Deprecated but still copied
DocumentType.INSTANCE,
DocumentType.LAYOUT,
]
// these documents don't really exist, they are part of other
// documents or enriched into existence as part of get requests
export enum VirtualDocumentType {

View file

@ -11,7 +11,7 @@ export enum Feature {
SYNC_AUTOMATIONS = "syncAutomations",
APP_BUILDERS = "appBuilders",
OFFLINE = "offline",
USER_ROLE_PUBLIC_API = "userRolePublicApi",
EXPANDED_PUBLIC_API = "expandedPublicApi",
VIEW_PERMISSIONS = "viewPermissions",
}