forked from tsai/budibase
135 changed files with 3708 additions and 1587 deletions
@ -0,0 +1,46 @@ |
|||||
|
name: Budibase Smoke Test |
||||
|
|
||||
|
on: |
||||
|
workflow_dispatch: |
||||
|
|
||||
|
jobs: |
||||
|
release: |
||||
|
runs-on: ubuntu-latest |
||||
|
|
||||
|
steps: |
||||
|
- uses: actions/checkout@v2 |
||||
|
- name: Use Node.js 14.x |
||||
|
uses: actions/setup-node@v1 |
||||
|
with: |
||||
|
node-version: 14.x |
||||
|
- run: yarn |
||||
|
- run: yarn bootstrap |
||||
|
- run: yarn build |
||||
|
- name: Pull cypress.env.yaml from budibase-infra |
||||
|
run: | |
||||
|
curl -H "Authorization: token ${{ secrets.GH_PERSONAL_TOKEN }}" \ |
||||
|
-H 'Accept: application/vnd.github.v3.raw' \ |
||||
|
-o packages/builder/cypress.env.json \ |
||||
|
-L https://api.github.com/repos/budibase/budibase-infra/contents/test/cypress.env.json |
||||
|
wc -l packages/builder/cypress.env.json |
||||
|
- run: yarn test:e2e:ci |
||||
|
env: |
||||
|
CI: true |
||||
|
name: Budibase CI |
||||
|
|
||||
|
# TODO: upload recordings to s3 |
||||
|
# - 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 |
||||
|
|
||||
|
# TODO look at cypress reporters |
||||
|
# - 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 }} |
||||
|
|
||||
@ -0,0 +1 @@ |
|||||
|
module.exports = require("./src/migrations") |
||||
@ -1,16 +1,26 @@ |
|||||
export const Cookies = { |
export const Cookies = { |
||||
Auth: "budibase:auth", |
Auth: "budibase:auth", |
||||
CurrentApp: "budibase:currentapp", |
CurrentApp: "budibase:currentapp", |
||||
|
ReturnUrl: "budibase:returnurl", |
||||
|
} |
||||
|
|
||||
|
export function setCookie(name, value) { |
||||
|
if (getCookie(name)) { |
||||
|
removeCookie(name) |
||||
|
} |
||||
|
window.document.cookie = `${name}=${value}; Path=/;` |
||||
} |
} |
||||
|
|
||||
export function getCookie(cookieName) { |
export function getCookie(cookieName) { |
||||
return document.cookie.split(";").some(cookie => { |
const value = `; ${document.cookie}` |
||||
return cookie.trim().startsWith(`${cookieName}=`) |
const parts = value.split(`; ${cookieName}=`) |
||||
}) |
if (parts.length === 2) { |
||||
|
return parts[1].split(";").shift() |
||||
|
} |
||||
} |
} |
||||
|
|
||||
export function removeCookie(cookieName) { |
export function removeCookie(cookieName) { |
||||
if (getCookie(cookieName)) { |
if (getCookie(cookieName)) { |
||||
document.cookie = `${cookieName}=; Max-Age=-99999999;` |
document.cookie = `${cookieName}=; Max-Age=-99999999; Path=/;` |
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -1,34 +0,0 @@ |
|||||
import { writable } from "svelte/store" |
|
||||
import api, { get } from "../api" |
|
||||
|
|
||||
const INITIAL_HOSTING_UI_STATE = { |
|
||||
appUrl: "", |
|
||||
deployedApps: {}, |
|
||||
deployedAppNames: [], |
|
||||
deployedAppUrls: [], |
|
||||
} |
|
||||
|
|
||||
export const getHostingStore = () => { |
|
||||
const store = writable({ ...INITIAL_HOSTING_UI_STATE }) |
|
||||
store.actions = { |
|
||||
fetch: async () => { |
|
||||
const response = await api.get("/api/hosting/urls") |
|
||||
const urls = await response.json() |
|
||||
store.update(state => { |
|
||||
state.appUrl = urls.app |
|
||||
return state |
|
||||
}) |
|
||||
}, |
|
||||
fetchDeployedApps: async () => { |
|
||||
let deployments = await (await get("/api/hosting/apps")).json() |
|
||||
store.update(state => { |
|
||||
state.deployedApps = deployments |
|
||||
state.deployedAppNames = Object.values(deployments).map(app => app.name) |
|
||||
state.deployedAppUrls = Object.values(deployments).map(app => app.url) |
|
||||
return state |
|
||||
}) |
|
||||
return deployments |
|
||||
}, |
|
||||
} |
|
||||
return store |
|
||||
} |
|
||||
@ -1,13 +1,38 @@ |
|||||
<script> |
<script> |
||||
import { Body } from "@budibase/bbui" |
import { Label, Body, Layout } from "@budibase/bbui" |
||||
|
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" |
||||
|
|
||||
|
export let parameters |
||||
|
export let bindings = [] |
||||
</script> |
</script> |
||||
|
|
||||
<div class="root"> |
<div class="root"> |
||||
<Body size="S">This action doesn't require any additional settings.</Body> |
<Layout noPadding gap="M"> |
||||
|
<Body size="S"> |
||||
|
Please enter the URL you would like to be redirected to after logging out. |
||||
|
If you don't enter a value, you'll be redirected to the login screen. |
||||
|
</Body> |
||||
|
<div class="content"> |
||||
|
<Label small>Redirect URL</Label> |
||||
|
<DrawerBindableInput |
||||
|
title="Return URL" |
||||
|
value={parameters.redirectUrl} |
||||
|
on:change={value => (parameters.redirectUrl = value.detail)} |
||||
|
{bindings} |
||||
|
/> |
||||
|
</div> |
||||
|
</Layout> |
||||
</div> |
</div> |
||||
|
|
||||
<style> |
<style> |
||||
.root { |
.root { |
||||
|
max-width: 400px; |
||||
margin: 0 auto; |
margin: 0 auto; |
||||
} |
} |
||||
|
.content { |
||||
|
display: grid; |
||||
|
align-items: center; |
||||
|
gap: var(--spacing-m); |
||||
|
grid-template-columns: auto 1fr; |
||||
|
} |
||||
</style> |
</style> |
||||
|
|||||
@ -0,0 +1,33 @@ |
|||||
|
<script> |
||||
|
import { Select, Label } from "@budibase/bbui" |
||||
|
import { currentAsset } from "builderStore" |
||||
|
import { findAllMatchingComponents } from "builderStore/componentUtils" |
||||
|
|
||||
|
export let parameters |
||||
|
|
||||
|
$: components = findAllMatchingComponents($currentAsset.props, component => |
||||
|
component._component.endsWith("s3upload") |
||||
|
) |
||||
|
</script> |
||||
|
|
||||
|
<div class="root"> |
||||
|
<Label small>S3 Upload Component</Label> |
||||
|
<Select |
||||
|
bind:value={parameters.componentId} |
||||
|
options={components} |
||||
|
getOptionLabel={x => x._instanceName} |
||||
|
getOptionValue={x => x._id} |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
.root { |
||||
|
display: grid; |
||||
|
column-gap: var(--spacing-l); |
||||
|
row-gap: var(--spacing-s); |
||||
|
grid-template-columns: 120px 1fr; |
||||
|
align-items: center; |
||||
|
max-width: 400px; |
||||
|
margin: 0 auto; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,15 @@ |
|||||
|
<script> |
||||
|
import { Select } from "@budibase/bbui" |
||||
|
import { datasources } from "stores/backend" |
||||
|
|
||||
|
export let value = null |
||||
|
|
||||
|
$: dataSources = $datasources.list |
||||
|
.filter(ds => ds.source === "S3" && !ds.config?.endpoint) |
||||
|
.map(ds => ({ |
||||
|
label: ds.name, |
||||
|
value: ds._id, |
||||
|
})) |
||||
|
</script> |
||||
|
|
||||
|
<Select options={dataSources} {value} on:change /> |
||||
@ -0,0 +1,47 @@ |
|||||
|
<script> |
||||
|
import { Multiselect } from "@budibase/bbui" |
||||
|
import { |
||||
|
getDatasourceForProvider, |
||||
|
getSchemaForDatasource, |
||||
|
} from "builderStore/dataBinding" |
||||
|
import { currentAsset } from "builderStore" |
||||
|
import { tables } from "stores/backend" |
||||
|
import { createEventDispatcher } from "svelte" |
||||
|
import { getFields } from "helpers/searchFields" |
||||
|
|
||||
|
export let componentInstance = {} |
||||
|
export let value = "" |
||||
|
export let placeholder |
||||
|
|
||||
|
const dispatch = createEventDispatcher() |
||||
|
$: datasource = getDatasourceForProvider($currentAsset, componentInstance) |
||||
|
$: schema = getSchemaForDatasource($currentAsset, datasource).schema |
||||
|
$: options = getOptions(datasource, schema || {}) |
||||
|
$: boundValue = getSelectedOption(value, options) |
||||
|
|
||||
|
function getOptions(ds, dsSchema) { |
||||
|
let base = Object.values(dsSchema) |
||||
|
if (!ds?.tableId) { |
||||
|
return base |
||||
|
} |
||||
|
const currentTable = $tables.list.find(table => table._id === ds.tableId) |
||||
|
return getFields(base, { allowLinks: currentTable.sql }).map( |
||||
|
field => field.name |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
function getSelectedOption(selectedOptions, allOptions) { |
||||
|
// Fix the hardcoded default string value |
||||
|
if (!Array.isArray(selectedOptions)) { |
||||
|
selectedOptions = [] |
||||
|
} |
||||
|
return selectedOptions.filter(val => allOptions.indexOf(val) !== -1) |
||||
|
} |
||||
|
|
||||
|
const setValue = value => { |
||||
|
boundValue = getSelectedOption(value.detail, options) |
||||
|
dispatch("change", boundValue) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<Multiselect {placeholder} value={boundValue} on:change={setValue} {options} /> |
||||
@ -1,69 +0,0 @@ |
|||||
<script> |
|
||||
import { Heading, Layout, Icon } from "@budibase/bbui" |
|
||||
|
|
||||
export let onSelect |
|
||||
</script> |
|
||||
|
|
||||
<Layout gap="XS" noPadding> |
|
||||
<div class="template start-from-scratch" on:click={() => onSelect(null)}> |
|
||||
<div |
|
||||
class="background-icon" |
|
||||
style={`background: rgb(50, 50, 50); color: white;`} |
|
||||
> |
|
||||
<Icon name="Add" /> |
|
||||
</div> |
|
||||
<Heading size="XS">Start from scratch</Heading> |
|
||||
<p class="detail">BLANK</p> |
|
||||
</div> |
|
||||
<div |
|
||||
class="template import" |
|
||||
on:click={() => onSelect(null, { useImport: true })} |
|
||||
> |
|
||||
<div |
|
||||
class="background-icon" |
|
||||
style={`background: rgb(50, 50, 50); color: white;`} |
|
||||
> |
|
||||
<Icon name="Add" /> |
|
||||
</div> |
|
||||
<Heading size="XS">Import an app</Heading> |
|
||||
<p class="detail">BLANK</p> |
|
||||
</div> |
|
||||
</Layout> |
|
||||
|
|
||||
<style> |
|
||||
.background-icon { |
|
||||
padding: 10px; |
|
||||
border-radius: 4px; |
|
||||
display: flex; |
|
||||
align-items: center; |
|
||||
justify-content: center; |
|
||||
width: 18px; |
|
||||
color: white; |
|
||||
} |
|
||||
|
|
||||
.template { |
|
||||
min-height: 60px; |
|
||||
display: grid; |
|
||||
grid-gap: var(--layout-s); |
|
||||
grid-template-columns: auto 1fr auto; |
|
||||
border: 1px solid #494949; |
|
||||
align-items: center; |
|
||||
cursor: pointer; |
|
||||
border-radius: 4px; |
|
||||
background: var(--background-alt); |
|
||||
padding: 8px 16px; |
|
||||
} |
|
||||
|
|
||||
.detail { |
|
||||
text-align: right; |
|
||||
} |
|
||||
|
|
||||
.start-from-scratch { |
|
||||
background: var(--spectrum-global-color-gray-50); |
|
||||
margin-top: 20px; |
|
||||
} |
|
||||
|
|
||||
.import { |
|
||||
background: var(--spectrum-global-color-gray-50); |
|
||||
} |
|
||||
</style> |
|
||||
@ -1,120 +1,75 @@ |
|||||
<script> |
<script> |
||||
import { writable, get as svelteGet } from "svelte/store" |
import { writable, get as svelteGet } from "svelte/store" |
||||
import { |
import { notifications, Input, ModalContent, Body } from "@budibase/bbui" |
||||
notifications, |
|
||||
Input, |
|
||||
Modal, |
|
||||
ModalContent, |
|
||||
Body, |
|
||||
} from "@budibase/bbui" |
|
||||
import { hostingStore } from "builderStore" |
|
||||
import { apps } from "stores/portal" |
import { apps } from "stores/portal" |
||||
import { string, object } from "yup" |
|
||||
import { onMount } from "svelte" |
import { onMount } from "svelte" |
||||
import { capitalise } from "helpers" |
import { createValidationStore } from "helpers/validation/yup" |
||||
import { APP_NAME_REGEX } from "constants" |
import * as appValidation from "helpers/validation/yup/app" |
||||
|
|
||||
const values = writable({ name: null }) |
|
||||
const errors = writable({}) |
|
||||
const touched = writable({}) |
|
||||
const validator = { |
|
||||
name: string() |
|
||||
.trim() |
|
||||
.required("Your application must have a name") |
|
||||
.matches( |
|
||||
APP_NAME_REGEX, |
|
||||
"App name must be letters, numbers and spaces only" |
|
||||
), |
|
||||
} |
|
||||
|
|
||||
export let app |
export let app |
||||
|
|
||||
let modal |
const values = writable({ name: "", url: null }) |
||||
let valid = false |
const validation = createValidationStore() |
||||
let dirty = false |
$: validation.check($values) |
||||
$: checkValidity($values, validator) |
|
||||
$: { |
|
||||
// prevent validation by setting name to undefined without an app |
|
||||
if (app) { |
|
||||
$values.name = app?.name |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
onMount(async () => { |
onMount(async () => { |
||||
await hostingStore.actions.fetchDeployedApps() |
$values.name = app.name |
||||
const existingAppNames = svelteGet(hostingStore).deployedAppNames |
$values.url = app.url |
||||
validator.name = string() |
setupValidation() |
||||
.trim() |
|
||||
.required("Your application must have a name") |
|
||||
.matches( |
|
||||
APP_NAME_REGEX, |
|
||||
"App name must be letters, numbers and spaces only" |
|
||||
) |
|
||||
.test( |
|
||||
"non-existing-app-name", |
|
||||
"Another app with the same name already exists", |
|
||||
value => { |
|
||||
return !existingAppNames.some( |
|
||||
appName => dirty && appName.toLowerCase() === value.toLowerCase() |
|
||||
) |
|
||||
} |
|
||||
) |
|
||||
}) |
}) |
||||
|
|
||||
const checkValidity = async (values, validator) => { |
const setupValidation = async () => { |
||||
const obj = object().shape(validator) |
const applications = svelteGet(apps) |
||||
Object.keys(validator).forEach(key => ($errors[key] = null)) |
appValidation.name(validation, { apps: applications, currentApp: app }) |
||||
try { |
appValidation.url(validation, { apps: applications, currentApp: app }) |
||||
await obj.validate(values, { abortEarly: false }) |
// init validation |
||||
} catch (validationErrors) { |
validation.check($values) |
||||
validationErrors.inner.forEach(error => { |
|
||||
$errors[error.path] = capitalise(error.message) |
|
||||
}) |
|
||||
} |
|
||||
valid = await obj.isValid(values) |
|
||||
} |
} |
||||
|
|
||||
async function updateApp() { |
async function updateApp() { |
||||
try { |
try { |
||||
// Update App |
// Update App |
||||
await apps.update(app.instance._id, { name: $values.name.trim() }) |
const body = { |
||||
hide() |
name: $values.name.trim(), |
||||
|
} |
||||
|
if ($values.url) { |
||||
|
body.url = $values.url.trim() |
||||
|
} |
||||
|
await apps.update(app.instance._id, body) |
||||
} catch (error) { |
} catch (error) { |
||||
console.error(error) |
console.error(error) |
||||
notifications.error(error) |
notifications.error(error) |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
export const show = () => { |
// auto add slash to url |
||||
modal.show() |
$: { |
||||
} |
if ($values.url && !$values.url.startsWith("/")) { |
||||
export const hide = () => { |
$values.url = `/${$values.url}` |
||||
modal.hide() |
} |
||||
} |
|
||||
|
|
||||
const onCancel = () => { |
|
||||
hide() |
|
||||
} |
|
||||
|
|
||||
const onShow = () => { |
|
||||
dirty = false |
|
||||
} |
} |
||||
</script> |
</script> |
||||
|
|
||||
<Modal bind:this={modal} on:hide={onCancel} on:show={onShow}> |
<ModalContent |
||||
<ModalContent |
title={"Edit app"} |
||||
title={"Edit app"} |
confirmText={"Save"} |
||||
confirmText={"Save"} |
onConfirm={updateApp} |
||||
onConfirm={updateApp} |
disabled={!$validation.valid} |
||||
disabled={!(valid && dirty)} |
> |
||||
> |
<Body size="S">Update the name of your app.</Body> |
||||
<Body size="S">Update the name of your app.</Body> |
<Input |
||||
<Input |
bind:value={$values.name} |
||||
bind:value={$values.name} |
error={$validation.touched.name && $validation.errors.name} |
||||
error={$touched.name && $errors.name} |
on:blur={() => ($validation.touched.name = true)} |
||||
on:blur={() => ($touched.name = true)} |
label="Name" |
||||
on:change={() => (dirty = true)} |
/> |
||||
label="Name" |
<Input |
||||
/> |
bind:value={$values.url} |
||||
</ModalContent> |
error={$validation.touched.url && $validation.errors.url} |
||||
</Modal> |
on:blur={() => ($validation.touched.url = true)} |
||||
|
label="URL" |
||||
|
placeholder={$values.name |
||||
|
? "/" + encodeURIComponent($values.name).toLowerCase() |
||||
|
: "/"} |
||||
|
/> |
||||
|
</ModalContent> |
||||
|
|||||
@ -0,0 +1,31 @@ |
|||||
|
import { tables } from "../stores/backend" |
||||
|
import { BannedSearchTypes } from "../constants/backend" |
||||
|
import { get } from "svelte/store" |
||||
|
|
||||
|
export function getTableFields(linkField) { |
||||
|
const table = get(tables).list.find(table => table._id === linkField.tableId) |
||||
|
if (!table || !table.sql) { |
||||
|
return [] |
||||
|
} |
||||
|
const linkFields = getFields(Object.values(table.schema), { |
||||
|
allowLinks: false, |
||||
|
}) |
||||
|
return linkFields.map(field => ({ |
||||
|
...field, |
||||
|
name: `${table.name}.${field.name}`, |
||||
|
})) |
||||
|
} |
||||
|
|
||||
|
export function getFields(fields, { allowLinks } = { allowLinks: true }) { |
||||
|
let filteredFields = fields.filter( |
||||
|
field => !BannedSearchTypes.includes(field.type) |
||||
|
) |
||||
|
if (allowLinks) { |
||||
|
const linkFields = fields.filter(field => field.type === "link") |
||||
|
for (let linkField of linkFields) { |
||||
|
// only allow one depth of SQL relationship filtering
|
||||
|
filteredFields = filteredFields.concat(getTableFields(linkField)) |
||||
|
} |
||||
|
} |
||||
|
return filteredFields |
||||
|
} |
||||
@ -0,0 +1,83 @@ |
|||||
|
import { string, mixed } from "yup" |
||||
|
import { APP_NAME_REGEX, APP_URL_REGEX } from "constants" |
||||
|
|
||||
|
export const name = (validation, { apps, currentApp } = { apps: [] }) => { |
||||
|
validation.addValidator( |
||||
|
"name", |
||||
|
string() |
||||
|
.trim() |
||||
|
.required("Your application must have a name") |
||||
|
.matches( |
||||
|
APP_NAME_REGEX, |
||||
|
"App name must be letters, numbers and spaces only" |
||||
|
) |
||||
|
.test( |
||||
|
"non-existing-app-name", |
||||
|
"Another app with the same name already exists", |
||||
|
value => { |
||||
|
if (!value) { |
||||
|
// exit early, above validator will fail
|
||||
|
return true |
||||
|
} |
||||
|
if (currentApp) { |
||||
|
// filter out the current app if present
|
||||
|
apps = apps.filter(app => app.appId !== currentApp.appId) |
||||
|
} |
||||
|
return !apps |
||||
|
.map(app => app.name) |
||||
|
.some(appName => appName.toLowerCase() === value.toLowerCase()) |
||||
|
} |
||||
|
) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export const url = (validation, { apps, currentApp } = { apps: [] }) => { |
||||
|
validation.addValidator( |
||||
|
"url", |
||||
|
string() |
||||
|
.nullable() |
||||
|
.matches(APP_URL_REGEX, "App URL must not contain spaces") |
||||
|
.test( |
||||
|
"non-existing-app-url", |
||||
|
"Another app with the same URL already exists", |
||||
|
value => { |
||||
|
// url is nullable
|
||||
|
if (!value) { |
||||
|
return true |
||||
|
} |
||||
|
if (currentApp) { |
||||
|
// filter out the current app if present
|
||||
|
apps = apps.filter(app => app.appId !== currentApp.appId) |
||||
|
} |
||||
|
return !apps |
||||
|
.map(app => app.url) |
||||
|
.some(appUrl => appUrl?.toLowerCase() === value.toLowerCase()) |
||||
|
} |
||||
|
) |
||||
|
.test("valid-url", "Not a valid URL", value => { |
||||
|
// url is nullable
|
||||
|
if (!value) { |
||||
|
return true |
||||
|
} |
||||
|
// make it clear that this is a url path and cannot be a full url
|
||||
|
return ( |
||||
|
value.startsWith("/") && |
||||
|
!value.includes("http") && |
||||
|
!value.includes("www") && |
||||
|
!value.includes(".") && |
||||
|
value.length > 1 // just '/' is not valid
|
||||
|
) |
||||
|
}) |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
export const file = (validation, { template } = {}) => { |
||||
|
const templateToUse = |
||||
|
template && Object.keys(template).length === 0 ? null : template |
||||
|
validation.addValidator( |
||||
|
"file", |
||||
|
templateToUse?.fromFile |
||||
|
? mixed().required("Please choose a file to import") |
||||
|
: null |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,66 @@ |
|||||
|
import { capitalise } from "helpers" |
||||
|
import { object } from "yup" |
||||
|
import { writable, get } from "svelte/store" |
||||
|
import { notifications } from "@budibase/bbui" |
||||
|
|
||||
|
export const createValidationStore = () => { |
||||
|
const DEFAULT = { |
||||
|
errors: {}, |
||||
|
touched: {}, |
||||
|
valid: false, |
||||
|
} |
||||
|
|
||||
|
const validator = {} |
||||
|
const validation = writable(DEFAULT) |
||||
|
|
||||
|
const addValidator = (propertyName, propertyValidator) => { |
||||
|
if (!propertyValidator || !propertyName) { |
||||
|
return |
||||
|
} |
||||
|
validator[propertyName] = propertyValidator |
||||
|
} |
||||
|
|
||||
|
const check = async values => { |
||||
|
const obj = object().shape(validator) |
||||
|
// clear the previous errors
|
||||
|
const properties = Object.keys(validator) |
||||
|
properties.forEach(property => (get(validation).errors[property] = null)) |
||||
|
|
||||
|
let validationError = false |
||||
|
try { |
||||
|
await obj.validate(values, { abortEarly: false }) |
||||
|
} catch (error) { |
||||
|
if (!error.inner) { |
||||
|
notifications.error("Unexpected validation error", error) |
||||
|
validationError = true |
||||
|
} else { |
||||
|
error.inner.forEach(err => { |
||||
|
validation.update(store => { |
||||
|
store.errors[err.path] = capitalise(err.message) |
||||
|
return store |
||||
|
}) |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
let valid |
||||
|
if (properties.length && !validationError) { |
||||
|
valid = await obj.isValid(values) |
||||
|
} else { |
||||
|
// don't say valid until validators have been loaded
|
||||
|
valid = false |
||||
|
} |
||||
|
|
||||
|
validation.update(store => { |
||||
|
store.valid = valid |
||||
|
return store |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
subscribe: validation.subscribe, |
||||
|
set: validation.set, |
||||
|
check, |
||||
|
addValidator, |
||||
|
} |
||||
|
} |
||||
File diff suppressed because it is too large
@ -0,0 +1,143 @@ |
|||||
|
<script> |
||||
|
import Field from "./Field.svelte" |
||||
|
import { CoreDropzone, ProgressCircle } from "@budibase/bbui" |
||||
|
import { getContext, onMount, onDestroy } from "svelte" |
||||
|
|
||||
|
export let datasourceId |
||||
|
export let bucket |
||||
|
export let key |
||||
|
export let field |
||||
|
export let label |
||||
|
export let disabled = false |
||||
|
export let validation |
||||
|
|
||||
|
let fieldState |
||||
|
let fieldApi |
||||
|
|
||||
|
const { API, notificationStore, uploadStore } = getContext("sdk") |
||||
|
const component = getContext("component") |
||||
|
|
||||
|
// 5GB cap per item sent via S3 REST API |
||||
|
const MaxFileSize = 1000000000 * 5 |
||||
|
|
||||
|
// Actual file data to upload |
||||
|
let data |
||||
|
let loading = false |
||||
|
|
||||
|
const handleFileTooLarge = () => { |
||||
|
notificationStore.actions.warning( |
||||
|
"Files cannot exceed 5GB. Please try again with a smaller file." |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
// Process the file input and return a serializable structure expected by |
||||
|
// the dropzone component to display the file |
||||
|
const processFiles = async fileList => { |
||||
|
return await new Promise(resolve => { |
||||
|
if (!fileList?.length) { |
||||
|
return [] |
||||
|
} |
||||
|
|
||||
|
// Don't read in non-image files |
||||
|
data = fileList[0] |
||||
|
if (!data.type?.startsWith("image")) { |
||||
|
resolve([ |
||||
|
{ |
||||
|
name: data.name, |
||||
|
type: data.type, |
||||
|
}, |
||||
|
]) |
||||
|
} |
||||
|
|
||||
|
// Read image files and display as preview |
||||
|
const reader = new FileReader() |
||||
|
reader.addEventListener( |
||||
|
"load", |
||||
|
() => { |
||||
|
resolve([ |
||||
|
{ |
||||
|
url: reader.result, |
||||
|
name: data.name, |
||||
|
type: data.type, |
||||
|
}, |
||||
|
]) |
||||
|
}, |
||||
|
false |
||||
|
) |
||||
|
reader.readAsDataURL(fileList[0]) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
const upload = async () => { |
||||
|
loading = true |
||||
|
try { |
||||
|
const res = await API.externalUpload(datasourceId, bucket, key, data) |
||||
|
notificationStore.actions.success("File uploaded successfully") |
||||
|
loading = false |
||||
|
return res |
||||
|
} catch (error) { |
||||
|
notificationStore.actions.error(`Error uploading file: ${error}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onMount(() => { |
||||
|
uploadStore.actions.registerFileUpload($component.id, upload) |
||||
|
}) |
||||
|
|
||||
|
onDestroy(() => { |
||||
|
uploadStore.actions.unregisterFileUpload($component.id) |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<Field |
||||
|
{label} |
||||
|
{field} |
||||
|
{disabled} |
||||
|
{validation} |
||||
|
type="s3upload" |
||||
|
bind:fieldState |
||||
|
bind:fieldApi |
||||
|
defaultValue={[]} |
||||
|
> |
||||
|
<div class="content"> |
||||
|
{#if fieldState} |
||||
|
<CoreDropzone |
||||
|
value={fieldState.value} |
||||
|
disabled={loading || fieldState.disabled} |
||||
|
error={fieldState.error} |
||||
|
on:change={e => { |
||||
|
fieldApi.setValue(e.detail) |
||||
|
}} |
||||
|
{processFiles} |
||||
|
{handleFileTooLarge} |
||||
|
maximum={1} |
||||
|
fileSizeLimit={MaxFileSize} |
||||
|
/> |
||||
|
{/if} |
||||
|
{#if loading} |
||||
|
<div class="overlay" /> |
||||
|
<div class="loading"> |
||||
|
<ProgressCircle /> |
||||
|
</div> |
||||
|
{/if} |
||||
|
</div> |
||||
|
</Field> |
||||
|
|
||||
|
<style> |
||||
|
.content { |
||||
|
position: relative; |
||||
|
} |
||||
|
.overlay, |
||||
|
.loading { |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
height: 100%; |
||||
|
width: 100%; |
||||
|
display: grid; |
||||
|
place-items: center; |
||||
|
} |
||||
|
.overlay { |
||||
|
background-color: var(--spectrum-global-color-gray-50); |
||||
|
opacity: 0.5; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,42 @@ |
|||||
|
import { writable, get } from "svelte/store" |
||||
|
|
||||
|
export const createUploadStore = () => { |
||||
|
const store = writable([]) |
||||
|
|
||||
|
// Registers a new file upload component
|
||||
|
const registerFileUpload = (componentId, callback) => { |
||||
|
if (!componentId || !callback) { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
store.update(state => { |
||||
|
state.push({ |
||||
|
componentId, |
||||
|
callback, |
||||
|
}) |
||||
|
return state |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// Unregisters a file upload component
|
||||
|
const unregisterFileUpload = componentId => { |
||||
|
store.update(state => state.filter(c => c.componentId !== componentId)) |
||||
|
} |
||||
|
|
||||
|
// Processes a file upload for a given component ID
|
||||
|
const processFileUpload = async componentId => { |
||||
|
if (!componentId) { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
const component = get(store).find(c => c.componentId === componentId) |
||||
|
return await component?.callback() |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
subscribe: store.subscribe, |
||||
|
actions: { registerFileUpload, unregisterFileUpload, processFileUpload }, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export const uploadStore = createUploadStore() |
||||
File diff suppressed because it is too large
@ -1,5 +1,5 @@ |
|||||
{ |
{ |
||||
"watch": ["src", "../auth"], |
"watch": ["src", "../backend-core"], |
||||
"ext": "js,ts,json", |
"ext": "js,ts,json", |
||||
"ignore": ["src/**/*.spec.ts", "src/**/*.spec.js"], |
"ignore": ["src/**/*.spec.ts", "src/**/*.spec.js"], |
||||
"exec": "ts-node src/index.ts" |
"exec": "ts-node src/index.ts" |
||||
|
|||||
@ -1,22 +0,0 @@ |
|||||
const CouchDB = require("../../db") |
|
||||
const { getDeployedApps } = require("../../utilities/workerRequests") |
|
||||
const { getScopedConfig } = require("@budibase/backend-core/db") |
|
||||
const { Configs } = require("@budibase/backend-core/constants") |
|
||||
const { checkSlashesInUrl } = require("../../utilities") |
|
||||
|
|
||||
exports.fetchUrls = async ctx => { |
|
||||
const appId = ctx.appId |
|
||||
const db = new CouchDB(appId) |
|
||||
const settings = await getScopedConfig(db, { type: Configs.SETTINGS }) |
|
||||
let appUrl = "http://localhost:10000/app" |
|
||||
if (settings && settings["platformUrl"]) { |
|
||||
appUrl = checkSlashesInUrl(`${settings["platformUrl"]}/app`) |
|
||||
} |
|
||||
ctx.body = { |
|
||||
app: appUrl, |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
exports.getDeployedApps = async ctx => { |
|
||||
ctx.body = await getDeployedApps() |
|
||||
} |
|
||||
@ -1,13 +0,0 @@ |
|||||
const Router = require("@koa/router") |
|
||||
const controller = require("../controllers/hosting") |
|
||||
const authorized = require("../../middleware/authorized") |
|
||||
const { BUILDER } = require("@budibase/backend-core/permissions") |
|
||||
|
|
||||
const router = Router() |
|
||||
|
|
||||
router |
|
||||
.get("/api/hosting/urls", authorized(BUILDER), controller.fetchUrls) |
|
||||
// this isn't risky, doesn't return anything about apps other than names and URLs
|
|
||||
.get("/api/hosting/apps", controller.getDeployedApps) |
|
||||
|
|
||||
module.exports = router |
|
||||
@ -1,36 +0,0 @@ |
|||||
// mock out node fetch for this
|
|
||||
jest.mock("node-fetch") |
|
||||
|
|
||||
const { checkBuilderEndpoint } = require("./utilities/TestFunctions") |
|
||||
const setup = require("./utilities") |
|
||||
|
|
||||
describe("/hosting", () => { |
|
||||
let request = setup.getRequest() |
|
||||
let config = setup.getConfig() |
|
||||
let app |
|
||||
|
|
||||
afterAll(setup.afterAll) |
|
||||
|
|
||||
beforeEach(async () => { |
|
||||
app = await config.init() |
|
||||
}) |
|
||||
|
|
||||
describe("fetchUrls", () => { |
|
||||
it("should be able to fetch current app URLs", async () => { |
|
||||
const res = await request |
|
||||
.get(`/api/hosting/urls`) |
|
||||
.set(config.defaultHeaders()) |
|
||||
.expect("Content-Type", /json/) |
|
||||
.expect(200) |
|
||||
expect(res.body.app).toEqual(`http://localhost:10000/app`) |
|
||||
}) |
|
||||
|
|
||||
it("should apply authorization to endpoint", async () => { |
|
||||
await checkBuilderEndpoint({ |
|
||||
config, |
|
||||
method: "GET", |
|
||||
url: `/api/hosting/urls`, |
|
||||
}) |
|
||||
}) |
|
||||
}) |
|
||||
}) |
|
||||
@ -0,0 +1,98 @@ |
|||||
|
jest.mock("node-fetch") |
||||
|
jest.mock("aws-sdk", () => ({ |
||||
|
config: { |
||||
|
update: jest.fn(), |
||||
|
}, |
||||
|
DynamoDB: { |
||||
|
DocumentClient: jest.fn(), |
||||
|
}, |
||||
|
S3: jest.fn(() => ({ |
||||
|
getSignedUrl: jest.fn(() => { |
||||
|
return "my-url" |
||||
|
}), |
||||
|
})), |
||||
|
})) |
||||
|
|
||||
|
const setup = require("./utilities") |
||||
|
|
||||
|
describe("/attachments", () => { |
||||
|
let request = setup.getRequest() |
||||
|
let config = setup.getConfig() |
||||
|
let app |
||||
|
|
||||
|
afterAll(setup.afterAll) |
||||
|
|
||||
|
beforeEach(async () => { |
||||
|
app = await config.init() |
||||
|
}) |
||||
|
|
||||
|
describe("generateSignedUrls", () => { |
||||
|
let datasource |
||||
|
|
||||
|
beforeEach(async () => { |
||||
|
datasource = await config.createDatasource({ |
||||
|
datasource: { |
||||
|
type: "datasource", |
||||
|
name: "Test", |
||||
|
source: "S3", |
||||
|
config: {}, |
||||
|
}, |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
it("should be able to generate a signed upload URL", async () => { |
||||
|
const bucket = "foo" |
||||
|
const key = "bar" |
||||
|
const res = await request |
||||
|
.post(`/api/attachments/${datasource._id}/url`) |
||||
|
.send({ bucket, key }) |
||||
|
.set(config.defaultHeaders()) |
||||
|
.expect("Content-Type", /json/) |
||||
|
.expect(200) |
||||
|
expect(res.body.signedUrl).toEqual("my-url") |
||||
|
expect(res.body.publicUrl).toEqual( |
||||
|
`https://${bucket}.s3.eu-west-1.amazonaws.com/${key}` |
||||
|
) |
||||
|
}) |
||||
|
|
||||
|
it("should handle an invalid datasource ID", async () => { |
||||
|
const res = await request |
||||
|
.post(`/api/attachments/foo/url`) |
||||
|
.send({ |
||||
|
bucket: "foo", |
||||
|
key: "bar", |
||||
|
}) |
||||
|
.set(config.defaultHeaders()) |
||||
|
.expect("Content-Type", /json/) |
||||
|
.expect(400) |
||||
|
expect(res.body.message).toEqual( |
||||
|
"The specified datasource could not be found" |
||||
|
) |
||||
|
}) |
||||
|
|
||||
|
it("should require a bucket parameter", async () => { |
||||
|
const res = await request |
||||
|
.post(`/api/attachments/${datasource._id}/url`) |
||||
|
.send({ |
||||
|
bucket: undefined, |
||||
|
key: "bar", |
||||
|
}) |
||||
|
.set(config.defaultHeaders()) |
||||
|
.expect("Content-Type", /json/) |
||||
|
.expect(400) |
||||
|
expect(res.body.message).toEqual("bucket and key values are required") |
||||
|
}) |
||||
|
|
||||
|
it("should require a key parameter", async () => { |
||||
|
const res = await request |
||||
|
.post(`/api/attachments/${datasource._id}/url`) |
||||
|
.send({ |
||||
|
bucket: "foo", |
||||
|
}) |
||||
|
.set(config.defaultHeaders()) |
||||
|
.expect("Content-Type", /json/) |
||||
|
.expect(400) |
||||
|
expect(res.body.message).toEqual("bucket and key values are required") |
||||
|
}) |
||||
|
}) |
||||
|
}) |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue