Merge pull request #4137 from Budibase/feature/app-urls

Custom App URLs
This commit is contained in:
Rory Powell 2022-01-26 14:55:36 +00:00 committed by GitHub
commit 716a693552
30 changed files with 361 additions and 423 deletions

View File

@ -1,6 +1,5 @@
import { getFrontendStore } from "./store/frontend" import { getFrontendStore } from "./store/frontend"
import { getAutomationStore } from "./store/automation" import { getAutomationStore } from "./store/automation"
import { getHostingStore } from "./store/hosting"
import { getThemeStore } from "./store/theme" import { getThemeStore } from "./store/theme"
import { derived, writable } from "svelte/store" import { derived, writable } from "svelte/store"
import { FrontendTypes, LAYOUT_NAMES } from "../constants" import { FrontendTypes, LAYOUT_NAMES } from "../constants"
@ -9,7 +8,6 @@ import { findComponent } from "./componentUtils"
export const store = getFrontendStore() export const store = getFrontendStore()
export const automationStore = getAutomationStore() export const automationStore = getAutomationStore()
export const themeStore = getThemeStore() export const themeStore = getThemeStore()
export const hostingStore = getHostingStore()
export const currentAsset = derived(store, $store => { export const currentAsset = derived(store, $store => {
const type = $store.currentFrontEndType const type = $store.currentFrontEndType

View File

@ -2,7 +2,6 @@ import { get, writable } from "svelte/store"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { import {
allScreens, allScreens,
hostingStore,
currentAsset, currentAsset,
mainLayout, mainLayout,
selectedComponent, selectedComponent,
@ -100,7 +99,6 @@ export const getFrontendStore = () => {
version: application.version, version: application.version,
revertableVersion: application.revertableVersion, revertableVersion: application.revertableVersion,
})) }))
await hostingStore.actions.fetch()
// Initialise backend stores // Initialise backend stores
const [_integrations] = await Promise.all([ const [_integrations] = await Promise.all([

View File

@ -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
}

View File

@ -6,7 +6,7 @@
import api from "builderStore/api" import api from "builderStore/api"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import CreateWebhookDeploymentModal from "./CreateWebhookDeploymentModal.svelte" import CreateWebhookDeploymentModal from "./CreateWebhookDeploymentModal.svelte"
import { store, hostingStore } from "builderStore" import { store } from "builderStore"
const DeploymentStatus = { const DeploymentStatus = {
SUCCESS: "SUCCESS", SUCCESS: "SUCCESS",
@ -37,7 +37,7 @@
let poll let poll
let deployments = [] let deployments = []
let urlComponent = $store.url || `/${appId}` let urlComponent = $store.url || `/${appId}`
let deploymentUrl = `${$hostingStore.appUrl}${urlComponent}` let deploymentUrl = `${urlComponent}`
const formatDate = (date, format) => const formatDate = (date, format) =>
Intl.DateTimeFormat("en-GB", DATE_OPTIONS[format]).format(date) Intl.DateTimeFormat("en-GB", DATE_OPTIONS[format]).format(date)

View File

@ -1,100 +1,46 @@
<script> <script>
import { writable, get as svelteGet } from "svelte/store" import { writable, get as svelteGet } from "svelte/store"
import { notifications, Input, ModalContent, Dropzone } from "@budibase/bbui" import { notifications, Input, ModalContent, Dropzone } from "@budibase/bbui"
import { store, automationStore, hostingStore } from "builderStore" import { store, automationStore } from "builderStore"
import { admin, auth } from "stores/portal" import { apps, admin, auth } from "stores/portal"
import { string, mixed, object } from "yup"
import api, { get, post } from "builderStore/api" import api, { get, post } from "builderStore/api"
import analytics, { Events } from "analytics" import analytics, { Events } from "analytics"
import { onMount } from "svelte" import { onMount } from "svelte"
import { capitalise } from "helpers"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { APP_NAME_REGEX } from "constants" import { createValidationStore } from "helpers/validation/yup"
import * as appValidation from "helpers/validation/yup/app"
export let template export let template
export let inline
const values = writable({ name: null }) const values = writable({ name: "", url: null })
const errors = writable({}) const validation = createValidationStore()
const touched = writable({}) $: validation.check($values)
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"
),
file: template?.fromFile
? mixed().required("Please choose a file to import")
: null,
}
let submitting = false
let valid = false
let initialTemplateInfo = template?.fromFile || template?.key
$: checkValidity($values, validator)
$: showTemplateSelection = !template && !initialTemplateInfo
onMount(async () => { onMount(async () => {
await hostingStore.actions.fetchDeployedApps() await setupValidation()
const existingAppNames = svelteGet(hostingStore).deployedAppNames
validator.name = string()
.trim()
.required("Your application must have a name")
.matches(APP_NAME_REGEX, "App name must be letters and numbers only")
.test(
"non-existing-app-name",
"Another app with the same name already exists",
value => {
return !existingAppNames.some(
appName => 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 })
if (template?.fromFile && values.file == null) { appValidation.url(validation, { apps: applications })
valid = false appValidation.file(validation, { template })
return // init validation
} validation.check($values)
try {
await obj.validate(values, { abortEarly: false })
} catch (validationErrors) {
validationErrors.inner.forEach(error => {
$errors[error.path] = capitalise(error.message)
})
}
valid = await obj.isValid(values)
} }
async function createNewApp() { async function createNewApp() {
const templateToUse = Object.keys(template).length === 0 ? null : template
submitting = true
// Check a template exists if we are important
if (templateToUse?.fromFile && !$values.file) {
$errors.file = "Please choose a file to import"
valid = false
submitting = false
return false
}
try { try {
// Create form data to create app // Create form data to create app
let data = new FormData() let data = new FormData()
data.append("name", $values.name.trim()) data.append("name", $values.name.trim())
data.append("useTemplate", templateToUse != null) if ($values.url) {
if (templateToUse) { data.append("url", $values.url.trim())
data.append("templateName", templateToUse.name) }
data.append("templateKey", templateToUse.key) data.append("useTemplate", template != null)
if (template) {
data.append("templateName", template.name)
data.append("templateKey", template.key)
data.append("templateFile", $values.file) data.append("templateFile", $values.file)
} }
@ -108,7 +54,7 @@
analytics.captureEvent(Events.APP.CREATED, { analytics.captureEvent(Events.APP.CREATED, {
name: $values.name, name: $values.name,
appId: appJson.instance._id, appId: appJson.instance._id,
templateToUse, templateToUse: template,
}) })
// Select Correct Application/DB in prep for creating user // Select Correct Application/DB in prep for creating user
@ -136,44 +82,51 @@
} catch (error) { } catch (error) {
console.error(error) console.error(error)
notifications.error(error) notifications.error(error)
submitting = false
} }
} }
async function onCancel() { // auto add slash to url
template = null $: {
await auth.setInitInfo({}) if ($values.url && !$values.url.startsWith("/")) {
$values.url = `/${$values.url}`
}
} }
</script> </script>
<ModalContent <ModalContent
title={"Name your app"} title={"Create your app"}
confirmText={template?.fromFile ? "Import app" : "Create app"} confirmText={template?.fromFile ? "Import app" : "Create app"}
onConfirm={createNewApp} onConfirm={createNewApp}
onCancel={inline ? onCancel : null} disabled={!$validation.valid}
cancelText={inline ? "Back" : undefined}
showCloseIcon={!inline}
disabled={!valid}
> >
{#if template?.fromFile} {#if template?.fromFile}
<Dropzone <Dropzone
error={$touched.file && $errors.file} error={$validation.touched.file && $validation.errors.file}
gallery={false} gallery={false}
label="File to import" label="File to import"
value={[$values.file]} value={[$values.file]}
on:change={e => { on:change={e => {
$values.file = e.detail?.[0] $values.file = e.detail?.[0]
$touched.file = true $validation.touched.file = true
}} }}
/> />
{/if} {/if}
<Input <Input
bind:value={$values.name} bind:value={$values.name}
error={$touched.name && $errors.name} error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($touched.name = true)} on:blur={() => ($validation.touched.name = true)}
label="Name" label="Name"
placeholder={$auth.user.firstName placeholder={$auth.user.firstName
? `${$auth.user.firstName}'s app` ? `${$auth.user.firstName}s app`
: "My app"} : "My app"}
/> />
<Input
bind:value={$values.url}
error={$validation.touched.url && $validation.errors.url}
on:blur={() => ($validation.touched.url = true)}
label="URL"
placeholder={$values.name
? "/" + encodeURIComponent($values.name).toLowerCase()
: "/"}
/>
</ModalContent> </ModalContent>

View File

@ -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("/")) {
$values.url = `/${$values.url}`
} }
export const hide = () => {
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={!(valid && dirty)} disabled={!$validation.valid}
> >
<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={$touched.name && $errors.name} error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($touched.name = true)} on:blur={() => ($validation.touched.name = true)}
on:change={() => (dirty = true)}
label="Name" label="Name"
/> />
<Input
bind:value={$values.url}
error={$validation.touched.url && $validation.errors.url}
on:blur={() => ($validation.touched.url = true)}
label="URL"
placeholder={$values.name
? "/" + encodeURIComponent($values.name).toLowerCase()
: "/"}
/>
</ModalContent> </ModalContent>
</Modal>

View File

@ -36,4 +36,7 @@ export const LAYOUT_NAMES = {
export const BUDIBASE_INTERNAL_DB = "bb_internal" export const BUDIBASE_INTERNAL_DB = "bb_internal"
// one or more word characters and whitespace
export const APP_NAME_REGEX = /^[\w\s]+$/ export const APP_NAME_REGEX = /^[\w\s]+$/
// zero or more non-whitespace characters
export const APP_URL_REGEX = /^\S*$/

View File

@ -1,5 +1,7 @@
import { writable, derived } from "svelte/store" import { writable, derived } from "svelte/store"
// DEPRECATED - Use the yup based validators for future validation
export function createValidationStore(initialValue, ...validators) { export function createValidationStore(initialValue, ...validators) {
let touched = false let touched = false

View File

@ -1,3 +1,5 @@
// TODO: Convert to yup based validators
export function emailValidator(value) { export function emailValidator(value) {
return ( return (
(value && (value &&

View File

@ -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
)
}

View File

@ -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,
}
}

View File

@ -12,7 +12,7 @@
Modal, Modal,
} from "@budibase/bbui" } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
import { apps, organisation, auth, admin } from "stores/portal" import { apps, organisation, auth } from "stores/portal"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import { gradient } from "actions" import { gradient } from "actions"
@ -34,7 +34,6 @@
const publishedAppsOnly = app => app.status === AppStatus.DEPLOYED const publishedAppsOnly = app => app.status === AppStatus.DEPLOYED
$: publishedApps = $apps.filter(publishedAppsOnly) $: publishedApps = $apps.filter(publishedAppsOnly)
$: isCloud = $admin.cloud
$: userApps = $auth.user?.builder?.global $: userApps = $auth.user?.builder?.global
? publishedApps ? publishedApps
: publishedApps.filter(app => : publishedApps.filter(app =>
@ -42,7 +41,11 @@
) )
function getUrl(app) { function getUrl(app) {
return !isCloud ? `/app/${encodeURIComponent(app.name)}` : `/${app.prodId}` if (app.url) {
return `/app${app.url}`
} else {
return `/${app.prodId}`
}
} }
</script> </script>

View File

@ -49,7 +49,6 @@
$: filteredApps = enrichedApps.filter(app => $: filteredApps = enrichedApps.filter(app =>
app?.name?.toLowerCase().includes(searchTerm.toLowerCase()) app?.name?.toLowerCase().includes(searchTerm.toLowerCase())
) )
$: isCloud = $admin.cloud
const enrichApps = (apps, user, sortBy) => { const enrichApps = (apps, user, sortBy) => {
const enrichedApps = apps.map(app => ({ const enrichedApps = apps.map(app => ({
@ -80,7 +79,7 @@
} }
const initiateAppCreation = () => { const initiateAppCreation = () => {
template = {} template = null
creationModal.show() creationModal.show()
creatingApp = true creatingApp = true
} }
@ -162,12 +161,10 @@
} }
const viewApp = app => { const viewApp = app => {
if (!isCloud && app.deployed) { if (app.url) {
// special case to use the short form name if self hosted window.open(`/app${app.url}`)
window.open(`/app/${encodeURIComponent(app.name)}`)
} else { } else {
const id = app.deployed ? app.prodId : app.devId window.open(`/${app.prodId}`)
window.open(`/${id}`, "_blank")
} }
} }
@ -442,6 +439,11 @@
> >
<CreateAppModal {template} /> <CreateAppModal {template} />
</Modal> </Modal>
<Modal bind:this={updatingModal} padding={false} width="600px">
<UpdateAppModal app={selectedApp} />
</Modal>
<ConfirmDialog <ConfirmDialog
bind:this={deletionModal} bind:this={deletionModal}
title="Confirm deletion" title="Confirm deletion"
@ -468,7 +470,6 @@
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>? Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog> </ConfirmDialog>
<UpdateAppModal app={selectedApp} bind:this={updatingModal} />
<ChooseIconModal app={selectedApp} bind:this={iconModal} /> <ChooseIconModal app={selectedApp} bind:this={iconModal} />
<style> <style>

View File

@ -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"

View File

@ -33,10 +33,7 @@ const {
Replication, Replication,
} = require("@budibase/backend-core/db") } = require("@budibase/backend-core/db")
const { USERS_TABLE_SCHEMA } = require("../../constants") const { USERS_TABLE_SCHEMA } = require("../../constants")
const { const { removeAppFromUserRoles } = require("../../utilities/workerRequests")
getDeployedApps,
removeAppFromUserRoles,
} = require("../../utilities/workerRequests")
const { clientLibraryPath, stringToReadStream } = require("../../utilities") const { clientLibraryPath, stringToReadStream } = require("../../utilities")
const { getAllLocks } = require("../../utilities/redis") const { getAllLocks } = require("../../utilities/redis")
const { const {
@ -78,29 +75,41 @@ function getUserRoleId(ctx) {
: ctx.user.role._id : ctx.user.role._id
} }
async function getAppUrlIfNotInUse(ctx) { async function getAppUrl(ctx) {
// construct the url
let url let url
if (ctx.request.body.url) { if (ctx.request.body.url) {
// if the url is provided, use that
url = encodeURI(ctx.request.body.url) url = encodeURI(ctx.request.body.url)
} else if (ctx.request.body.name) { } else {
// otherwise use the name
url = encodeURI(`${ctx.request.body.name}`) url = encodeURI(`${ctx.request.body.name}`)
} }
if (url) {
url = `/${url.replace(URL_REGEX_SLASH, "")}`.toLowerCase() url = `/${url.replace(URL_REGEX_SLASH, "")}`.toLowerCase()
}
if (!env.SELF_HOSTED) {
return url return url
} }
const deployedApps = await getDeployedApps()
if ( const checkAppUrl = (ctx, apps, url, currentAppId) => {
url && if (currentAppId) {
deployedApps[url] != null && apps = apps.filter(app => app.appId !== currentAppId)
ctx.params != null && }
deployedApps[url].appId !== ctx.params.appId if (apps.some(app => app.url === url)) {
) { ctx.throw(400, "App URL is already in use.")
ctx.throw(400, "App name/URL is already in use.") }
}
const checkAppName = (ctx, apps, name, currentAppId) => {
// TODO: Replace with Joi
if (!name) {
ctx.throw(400, "Name is required")
}
if (currentAppId) {
apps = apps.filter(app => app.appId !== currentAppId)
}
if (apps.some(app => app.name === name)) {
ctx.throw(400, "App name is already in use.")
} }
return url
} }
async function createInstance(template) { async function createInstance(template) {
@ -206,6 +215,12 @@ exports.fetchAppPackage = async ctx => {
} }
exports.create = async ctx => { exports.create = async ctx => {
const apps = await getAllApps(CouchDB, { dev: true })
const name = ctx.request.body.name
checkAppName(ctx, apps, name)
const url = await getAppUrl(ctx)
checkAppUrl(ctx, apps, url)
const { useTemplate, templateKey, templateString } = ctx.request.body const { useTemplate, templateKey, templateString } = ctx.request.body
const instanceConfig = { const instanceConfig = {
useTemplate, useTemplate,
@ -218,7 +233,6 @@ exports.create = async ctx => {
const instance = await createInstance(instanceConfig) const instance = await createInstance(instanceConfig)
const appId = instance._id const appId = instance._id
const url = await getAppUrlIfNotInUse(ctx)
const db = new CouchDB(appId) const db = new CouchDB(appId)
let _rev let _rev
try { try {
@ -235,7 +249,7 @@ exports.create = async ctx => {
type: "app", type: "app",
version: packageJson.version, version: packageJson.version,
componentLibraries: ["@budibase/standard-components"], componentLibraries: ["@budibase/standard-components"],
name: ctx.request.body.name, name: name,
url: url, url: url,
template: ctx.request.body.template, template: ctx.request.body.template,
instance: instance, instance: instance,
@ -263,7 +277,15 @@ exports.create = async ctx => {
} }
exports.update = async ctx => { exports.update = async ctx => {
const data = await updateAppPackage(ctx, ctx.request.body, ctx.params.appId) const apps = await getAllApps(CouchDB, { dev: true })
// validation
const name = ctx.request.body.name
checkAppName(ctx, apps, name, ctx.params.appId)
const url = await getAppUrl(ctx)
checkAppUrl(ctx, apps, url, ctx.params.appId)
const appPackageUpdates = { name, url }
const data = await updateAppPackage(appPackageUpdates, ctx.params.appId)
ctx.status = 200 ctx.status = 200
ctx.body = data ctx.body = data
} }
@ -285,7 +307,7 @@ exports.updateClient = async ctx => {
version: packageJson.version, version: packageJson.version,
revertableVersion: currentVersion, revertableVersion: currentVersion,
} }
const data = await updateAppPackage(ctx, appPackageUpdates, ctx.params.appId) const data = await updateAppPackage(appPackageUpdates, ctx.params.appId)
ctx.status = 200 ctx.status = 200
ctx.body = data ctx.body = data
} }
@ -308,7 +330,7 @@ exports.revertClient = async ctx => {
version: application.revertableVersion, version: application.revertableVersion,
revertableVersion: null, revertableVersion: null,
} }
const data = await updateAppPackage(ctx, appPackageUpdates, ctx.params.appId) const data = await updateAppPackage(appPackageUpdates, ctx.params.appId)
ctx.status = 200 ctx.status = 200
ctx.body = data ctx.body = data
} }
@ -381,12 +403,11 @@ exports.sync = async (ctx, next) => {
} }
} }
const updateAppPackage = async (ctx, appPackage, appId) => { const updateAppPackage = async (appPackage, appId) => {
const url = await getAppUrlIfNotInUse(ctx)
const db = new CouchDB(appId) const db = new CouchDB(appId)
const application = await db.get(DocumentTypes.APP_METADATA) const application = await db.get(DocumentTypes.APP_METADATA)
const newAppPackage = { ...application, ...appPackage, url } const newAppPackage = { ...application, ...appPackage }
if (appPackage._rev !== application._rev) { if (appPackage._rev !== application._rev) {
newAppPackage._rev = application._rev newAppPackage._rev = application._rev
} }

View File

@ -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()
}

View File

@ -5,7 +5,7 @@ const { resolve, join } = require("../../../utilities/centralPath")
const uuid = require("uuid") const uuid = require("uuid")
const { ObjectStoreBuckets } = require("../../../constants") const { ObjectStoreBuckets } = require("../../../constants")
const { processString } = require("@budibase/string-templates") const { processString } = require("@budibase/string-templates")
const { getDeployedApps } = require("../../../utilities/workerRequests") const { getAllApps } = require("@budibase/backend-core/db")
const CouchDB = require("../../../db") const CouchDB = require("../../../db")
const { const {
loadHandlebarsFile, loadHandlebarsFile,
@ -39,12 +39,18 @@ async function prepareUpload({ s3Key, bucket, metadata, file }) {
} }
} }
async function checkForSelfHostedURL(ctx) { async function getAppIdFromUrl(ctx) {
// the "appId" component of the URL may actually be a specific self hosted URL // the "appId" component of the URL can be the id or the custom url
let possibleAppUrl = `/${encodeURI(ctx.params.appId).toLowerCase()}` let possibleAppUrl = `/${encodeURI(ctx.params.appId).toLowerCase()}`
const apps = await getDeployedApps()
if (apps[possibleAppUrl] && apps[possibleAppUrl].appId) { // search prod apps for a url that matches, exclude dev where id is always used
return apps[possibleAppUrl].appId const apps = await getAllApps(CouchDB, { dev: false })
const app = apps.filter(
a => a.url && a.url.toLowerCase() === possibleAppUrl
)[0]
if (app && app.appId) {
return app.appId
} else { } else {
return ctx.params.appId return ctx.params.appId
} }
@ -77,10 +83,7 @@ exports.uploadFile = async function (ctx) {
} }
exports.serveApp = async function (ctx) { exports.serveApp = async function (ctx) {
let appId = ctx.params.appId let appId = await getAppIdFromUrl(ctx)
if (env.SELF_HOSTED) {
appId = await checkForSelfHostedURL(ctx)
}
const App = require("./templates/BudibaseApp.svelte").default const App = require("./templates/BudibaseApp.svelte").default
const db = new CouchDB(appId, { skip_setup: true }) const db = new CouchDB(appId, { skip_setup: true })
const appInfo = await db.get(DocumentTypes.APP_METADATA) const appInfo = await db.get(DocumentTypes.APP_METADATA)

View File

@ -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

View File

@ -20,7 +20,6 @@ const integrationRoutes = require("./integration")
const permissionRoutes = require("./permission") const permissionRoutes = require("./permission")
const datasourceRoutes = require("./datasource") const datasourceRoutes = require("./datasource")
const queryRoutes = require("./query") const queryRoutes = require("./query")
const hostingRoutes = require("./hosting")
const backupRoutes = require("./backup") const backupRoutes = require("./backup")
const metadataRoutes = require("./metadata") const metadataRoutes = require("./metadata")
const devRoutes = require("./dev") const devRoutes = require("./dev")
@ -46,7 +45,6 @@ exports.mainRoutes = [
permissionRoutes, permissionRoutes,
datasourceRoutes, datasourceRoutes,
queryRoutes, queryRoutes,
hostingRoutes,
backupRoutes, backupRoutes,
metadataRoutes, metadataRoutes,
devRoutes, devRoutes,

View File

@ -53,8 +53,8 @@ describe("/applications", () => {
describe("fetch", () => { describe("fetch", () => {
it("lists all applications", async () => { it("lists all applications", async () => {
await config.createApp(request, "app1") await config.createApp("app1")
await config.createApp(request, "app2") await config.createApp("app2")
const res = await request const res = await request
.get(`/api/applications?status=${AppStatus.DEV}`) .get(`/api/applications?status=${AppStatus.DEV}`)

View File

@ -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`,
})
})
})
})

View File

@ -153,8 +153,15 @@ export function isIsoDateString(str: string) {
* @param column The column to check, to see if it is a valid relationship. * @param column The column to check, to see if it is a valid relationship.
* @param tableIds The IDs of the tables which currently exist. * @param tableIds The IDs of the tables which currently exist.
*/ */
function shouldCopyRelationship(column: { type: string, tableId?: string }, tableIds: [string]) { function shouldCopyRelationship(
return column.type === FieldTypes.LINK && column.tableId && tableIds.includes(column.tableId) column: { type: string; tableId?: string },
tableIds: [string]
) {
return (
column.type === FieldTypes.LINK &&
column.tableId &&
tableIds.includes(column.tableId)
)
} }
/** /**
@ -165,9 +172,15 @@ function shouldCopyRelationship(column: { type: string, tableId?: string }, tabl
* @param column The column to check for options or boolean type. * @param column The column to check for options or boolean type.
* @param fetchedColumn The fetched column to check for the type in the external database. * @param fetchedColumn The fetched column to check for the type in the external database.
*/ */
function shouldCopySpecialColumn(column: { type: string }, fetchedColumn: { type: string } | undefined) { function shouldCopySpecialColumn(
return column.type === FieldTypes.OPTIONS || column: { type: string },
((!fetchedColumn || fetchedColumn.type === FieldTypes.NUMBER) && column.type === FieldTypes.BOOLEAN) fetchedColumn: { type: string } | undefined
) {
return (
column.type === FieldTypes.OPTIONS ||
((!fetchedColumn || fetchedColumn.type === FieldTypes.NUMBER) &&
column.type === FieldTypes.BOOLEAN)
)
} }
/** /**

View File

@ -27,7 +27,7 @@ describe("syncRows", () => {
await config.createTable() await config.createTable()
await config.createRow() await config.createRow()
// app 2 // app 2
await config.createApp() await config.createApp("second-app")
await config.createTable() await config.createTable()
await config.createRow() await config.createRow()
await config.createRow() await config.createRow()

View File

@ -22,6 +22,7 @@ const { getGlobalDB } = require("@budibase/backend-core/tenancy")
const { createASession } = require("@budibase/backend-core/sessions") const { createASession } = require("@budibase/backend-core/sessions")
const { user: userCache } = require("@budibase/backend-core/cache") const { user: userCache } = require("@budibase/backend-core/cache")
const CouchDB = require("../../db") const CouchDB = require("../../db")
const newid = require("../../db/newid")
core.init(CouchDB) core.init(CouchDB)
const GLOBAL_USER_ID = "us_uuid1" const GLOBAL_USER_ID = "us_uuid1"
@ -98,7 +99,8 @@ class TestConfiguration {
} }
} }
async init(appName = "test_application") { // use a new id as the name to avoid name collisions
async init(appName = newid()) {
await this.globalUser() await this.globalUser()
return this.createApp(appName) return this.createApp(appName)
} }

View File

@ -78,6 +78,11 @@ class QueryRunner {
return this.execute() return this.execute()
} }
// check for undefined response
if (!rows) {
rows = []
}
// needs to an array for next step // needs to an array for next step
if (!Array.isArray(rows)) { if (!Array.isArray(rows)) {
rows = [rows] rows = [rows]

View File

@ -58,29 +58,6 @@ exports.sendSmtpEmail = async (to, from, subject, contents, automation) => {
return response.json() return response.json()
} }
exports.getDeployedApps = async () => {
try {
const response = await fetch(
checkSlashesInUrl(env.WORKER_URL + `/api/apps`),
request(null, {
method: "GET",
})
)
const json = await response.json()
const apps = {}
for (let [key, value] of Object.entries(json)) {
if (value.url) {
value.url = value.url.toLowerCase()
apps[key.toLowerCase()] = value
}
}
return apps
} catch (err) {
// error, cannot determine deployed apps, don't stop app creation - sort this later
return {}
}
}
exports.getGlobalSelf = async (ctx, appId = null) => { exports.getGlobalSelf = async (ctx, appId = null) => {
const endpoint = `/api/global/users/self` const endpoint = `/api/global/users/self`
const response = await fetch( const response = await fetch(

View File

@ -1,3 +1,3 @@
{ {
"watch": ["src", "../auth"] "watch": ["src", "../backend-core"]
} }

View File

@ -1,30 +0,0 @@
const {
getAllApps,
getDeployedAppID,
isProdAppID,
} = require("@budibase/backend-core/db")
const CouchDB = require("../../db")
const URL_REGEX_SLASH = /\/|\\/g
exports.getApps = async ctx => {
const apps = await getAllApps(CouchDB, { all: true })
const body = {}
for (let app of apps) {
let url = app.url || encodeURI(`${app.name}`)
url = `/${url.replace(URL_REGEX_SLASH, "")}`
const appId = app.appId,
isProd = isProdAppID(app.appId)
if (!body[url]) {
body[url] = {
appId: getDeployedAppID(appId),
name: app.name,
url,
deployed: isProd,
}
} else {
body[url].deployed = isProd || body[url].deployed
}
}
ctx.body = body
}

View File

@ -1,8 +0,0 @@
const Router = require("@koa/router")
const controller = require("../controllers/app")
const router = Router()
router.get("/api/apps", controller.getApps)
module.exports = router

View File

@ -8,14 +8,12 @@ const roleRoutes = require("./global/roles")
const sessionRoutes = require("./global/sessions") const sessionRoutes = require("./global/sessions")
const environmentRoutes = require("./system/environment") const environmentRoutes = require("./system/environment")
const tenantsRoutes = require("./system/tenants") const tenantsRoutes = require("./system/tenants")
const appRoutes = require("./app")
exports.routes = [ exports.routes = [
configRoutes, configRoutes,
userRoutes, userRoutes,
workspaceRoutes, workspaceRoutes,
authRoutes, authRoutes,
appRoutes,
templateRoutes, templateRoutes,
tenantsRoutes, tenantsRoutes,
emailRoutes, emailRoutes,