This commit is contained in:
Martin McKeaveney 2021-01-11 20:29:33 +00:00
commit a48869a2f0
85 changed files with 3138 additions and 1122 deletions

View File

@ -61,3 +61,12 @@ jobs:
# macOS notarization API key
API_KEY_ID: ${{ secrets.api_key_id }}
API_KEY_ISSUER_ID: ${{ secrets.api_key_issuer_id }}
- name: Build/release Docker images
# only run the docker image build on linux, easiest way
if: startsWith(matrix.os, 'ubuntu')
env:
DOCKER_USER: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }}
run: docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
run: yarn build:docker

1
.gitignore vendored
View File

@ -62,6 +62,7 @@ typings/
# dotenv environment variables file
.env
!hosting/.env
# parcel-bundler cache (https://parceljs.org/)
.cache

1
hosting/.env Symbolic link
View File

@ -0,0 +1 @@
hosting.properties

View File

@ -0,0 +1,16 @@
version: "3"
services:
app-service:
build: ./server
volumes:
- ./server:/app
environment:
SELF_HOSTED: 1
PORT: 4002
worker-service:
build: ./worker
environment:
SELF_HOSTED: 1,
PORT: 4003

1
hosting/build/server Symbolic link
View File

@ -0,0 +1 @@
../../packages/server/

1
hosting/build/worker Symbolic link
View File

@ -0,0 +1 @@
../../packages/worker/

View File

@ -0,0 +1,90 @@
version: "3"
services:
app-service:
image: budibase/budibase-apps
ports:
- "${APP_PORT}:4002"
environment:
SELF_HOSTED: 1
COUCH_DB_URL: http://${COUCH_DB_USER}:${COUCH_DB_PASSWORD}@couchdb-service:5984
BUDIBASE_ENVIRONMENT: ${BUDIBASE_ENVIRONMENT}
LOGO_URL: ${LOGO_URL}
PORT: 4002
JWT_SECRET: ${JWT_SECRET}
depends_on:
- worker-service
worker-service:
image: budibase/budibase-worker
ports:
- "${WORKER_PORT}:4003"
environment:
SELF_HOSTED: 1,
DEPLOYMENT_API_KEY: ${WORKER_API_KEY}
PORT: 4003
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
RAW_MINIO_URL: http://minio-service:9000
COUCH_DB_USERNAME: ${COUCH_DB_USER}
COUCH_DB_PASSWORD: ${COUCH_DB_PASSWORD}
RAW_COUCH_DB_URL: http://couchdb-service:5984
SELF_HOST_KEY: ${HOSTING_KEY}
depends_on:
- minio-service
- couch-init
minio-service:
image: minio/minio
volumes:
- minio_data:/data
ports:
- "${MINIO_PORT}:9000"
environment:
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
command: server /data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
proxy-service:
image: envoyproxy/envoy:v1.16-latest
volumes:
- ./envoy.yaml:/etc/envoy/envoy.yaml
ports:
- "${MAIN_PORT}:10000"
- "9901:9901"
depends_on:
- minio-service
- worker-service
- app-service
- couchdb-service
couchdb-service:
image: apache/couchdb:3.0
environment:
- COUCHDB_PASSWORD=${COUCH_DB_PASSWORD}
- COUCHDB_USER=${COUCH_DB_USER}
ports:
- "${COUCH_DB_PORT}:5984"
- "4369:4369"
- "9100:9100"
volumes:
- couchdb_data:/couchdb
couch-init:
image: curlimages/curl
environment:
PUT_CALL: "curl -u ${COUCH_DB_USER}:${COUCH_DB_PASSWORD} -X PUT couchdb-service:5984"
depends_on:
- couchdb-service
command: ["sh","-c","sleep 10 && $${PUT_CALL}/_users && $${PUT_CALL}/_replicator; fg;"]
volumes:
couchdb_data:
driver: local
minio_data:
driver: local

104
hosting/envoy.yaml Normal file
View File

@ -0,0 +1,104 @@
static_resources:
listeners:
- name: main_listener
address:
socket_address: { address: 0.0.0.0, port_value: 10000 }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress
codec_type: auto
route_config:
name: local_route
virtual_hosts:
- name: local_services
domains: ["*"]
routes:
- match: { prefix: "/app/" }
route:
cluster: app-service
prefix_rewrite: "/"
# special case for when API requests are made, can just forward, not to minio
- match: { prefix: "/api/" }
route:
cluster: app-service
- match: { prefix: "/worker/" }
route:
cluster: worker-service
prefix_rewrite: "/"
- match: { prefix: "/db/" }
route:
cluster: couchdb-service
prefix_rewrite: "/"
# minio is on the default route because this works
# best, minio + AWS SDK doesn't handle path proxy
- match: { prefix: "/" }
route:
cluster: minio-service
http_filters:
- name: envoy.filters.http.router
clusters:
- name: app-service
connect_timeout: 0.25s
type: strict_dns
lb_policy: round_robin
load_assignment:
cluster_name: app-service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: app-service
port_value: 4002
- name: minio-service
connect_timeout: 0.25s
type: strict_dns
lb_policy: round_robin
load_assignment:
cluster_name: minio-service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: minio-service
port_value: 9000
- name: worker-service
connect_timeout: 0.25s
type: strict_dns
lb_policy: round_robin
load_assignment:
cluster_name: worker-service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: worker-service
port_value: 4003
- name: couchdb-service
connect_timeout: 0.25s
type: strict_dns
lb_policy: round_robin
load_assignment:
cluster_name: couchdb-service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: couchdb-service
port_value: 5984

View File

@ -0,0 +1,25 @@
# Use the main port in the builder for your self hosting URL, e.g. localhost:10000
MAIN_PORT=10000
# Use this password when configuring your self hosting settings
# This should be updated
HOSTING_KEY=budibase
# This section contains customisation options
LOGO_URL=https://logoipsum.com/logo/logo-15.svg
# This section contains all secrets pertaining to the system
# These should be updated
JWT_SECRET=testsecret
MINIO_ACCESS_KEY=budibase
MINIO_SECRET_KEY=budibase
COUCH_DB_PASSWORD=budibase
COUCH_DB_USER=budibase
WORKER_API_KEY=budibase
# This section contains variables that do not need to be altered under normal circumstances
APP_PORT=4002
WORKER_PORT=4003
MINIO_PORT=4004
COUCH_DB_PORT=4005
BUDIBASE_ENVIRONMENT=PRODUCTION

View File

@ -0,0 +1,4 @@
#!/bin/bash
sudo curl -L "https://github.com/docker/compose/releases/download/1.27.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
sudo ln -s /usr/local/bin/docker-compose /usr/bin/docker-compose

View File

@ -0,0 +1,6 @@
#!/bin/bash
echo "**** WARNING - not for production environments ****"
# warning this is a convience script, for production installations install docker
# properly for your environment!
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

View File

@ -0,0 +1,9 @@
#!/bin/bash
pushd ../../build
docker-compose build --force app-service
docker-compose build --force worker-service
docker tag build_app-service budibase/budibase-apps:latest
docker push budibase/budibase-apps
docker tag build_worker-service budibase/budibase-worker:latest
docker push budibase/budibase-worker
popd

2
hosting/start.sh Executable file
View File

@ -0,0 +1,2 @@
#!/bin/bash
docker-compose --env-file hosting.properties up

20
hosting/utils/testing.sh Executable file
View File

@ -0,0 +1,20 @@
#!/bin/bash
function dockerInstalled {
echo "Checking docker installation..."
if [ ! -x "$(command -v docker)" ]; then
echo "Please install docker to continue"
exit -1
fi
}
dockerInstalled
source "${BASH_SOURCE%/*}/hosting.properties"
opts="-e MINIO_ACCESS_KEY=$minio_access_key -e MINIO_SECRET_KEY=$minio_secret_key"
if [ -n "$minio_secret_key_old" ] && [ -n "$minio_access_key_old" ]; then
opts="$opts -e MINIO_SECRET_KEY_OLD=$minio_secret_key_old -e MINIO_ACCESS_KEY_OLD=$minio_access_key_old"
fi
docker run -p $minio_port:$minio_port $opts -v /mnt/data:/data minio/minio server /data

View File

@ -32,7 +32,8 @@
"lint:fix": "eslint --fix packages",
"format": "prettier --write \"{,!(node_modules)/**/}*.{js,jsx,svelte}\"",
"test:e2e": "lerna run cy:test",
"test:e2e:ci": "lerna run cy:ci"
"test:e2e:ci": "lerna run cy:ci",
"build:docker": "cd hosting/scripts/linux/ && ./release-to-docker-hub.sh && cd -"
},
"dependencies": {
"@fortawesome/fontawesome": "^1.1.8"

View File

@ -1,6 +1,8 @@
import { getFrontendStore } from "./store/frontend"
import { getBackendUiStore } from "./store/backend"
import { getAutomationStore } from "./store/automation/"
import { getAutomationStore } from "./store/automation"
import { getHostingStore } from "./store/hosting"
import { getThemeStore } from "./store/theme"
import { derived, writable } from "svelte/store"
import analytics from "analytics"
@ -11,6 +13,7 @@ export const store = getFrontendStore()
export const backendUiStore = getBackendUiStore()
export const automationStore = getAutomationStore()
export const themeStore = getThemeStore()
export const hostingStore = getHostingStore()
export const currentAsset = derived(store, $store => {
const type = $store.currentFrontEndType

View File

@ -7,6 +7,7 @@ import {
import {
allScreens,
backendUiStore,
hostingStore,
currentAsset,
mainLayout,
selectedComponent,
@ -71,6 +72,7 @@ export const getFrontendStore = () => {
appInstance: pkg.application.instance,
}))
await hostingStore.actions.fetch()
await backendUiStore.actions.database.select(pkg.application.instance)
},
routing: {

View File

@ -0,0 +1,38 @@
import { writable } from "svelte/store"
import api from "../api"
const INITIAL_BACKEND_UI_STATE = {
hostingInfo: {},
appUrl: "",
}
export const getHostingStore = () => {
const store = writable({ ...INITIAL_BACKEND_UI_STATE })
store.actions = {
fetch: async () => {
const responses = await Promise.all([
api.get("/api/hosting/"),
api.get("/api/hosting/urls"),
])
const [info, urls] = await Promise.all(responses.map(resp => resp.json()))
store.update(state => {
state.hostingInfo = info
state.appUrl = urls.app
return state
})
return info
},
save: async hostingInfo => {
const response = await api.post("/api/hosting", hostingInfo)
const revision = (await response.json()).rev
store.update(state => {
state.hostingInfo = {
...hostingInfo,
_rev: revision,
}
return state
})
},
}
return store
}

View File

@ -1,16 +1,17 @@
<script>
import { notifier } from "builderStore/store/notifications"
import { Input } from "@budibase/bbui"
import { store } from "builderStore"
import { store, hostingStore } from "builderStore"
export let value
export let production = false
$: appId = $store.appId
$: appUrl = $hostingStore.appUrl
function fullWebhookURL(uri) {
if (production) {
return `https://${appId}.app.budi.live/${uri}`
return `${appUrl}/${uri}`
} else {
return `http://localhost:4001/${uri}`
}

View File

@ -1,41 +0,0 @@
<script>
import {
DropdownMenu,
TextButton as Button,
Icon,
Modal,
ModalContent,
} from "@budibase/bbui"
import { backendUiStore } from "builderStore"
import api from "builderStore/api"
import EditIntegrationConfig from "../modals/EditIntegrationConfig.svelte"
export let table
let modal
// TODO: revisit
async function saveTable() {
const SAVE_TABLE_URL = `/api/tables`
const response = await api.post(SAVE_TABLE_URL, table)
const savedTable = await response.json()
await backendUiStore.actions.tables.fetch()
backendUiStore.actions.tables.select(savedTable)
}
</script>
<div>
<Button text small on:click={modal.show}>
<Icon name="edit" />
Configure Schema
</Button>
</div>
<Modal bind:this={modal}>
<ModalContent
confirmText="Save"
cancelText="Cancel"
onConfirm={saveTable}
title={'Datasource Configuration'}>
<EditIntegrationConfig onClosed={modal.hide} bind:table />
</ModalContent>
</Modal>

View File

@ -1,129 +0,0 @@
<script>
import {
Select,
Button,
Input,
TextArea,
Heading,
Spacer,
} from "@budibase/bbui"
import { notifier } from "builderStore/store/notifications"
import { FIELDS } from "constants/backend"
import { backendUiStore } from "builderStore"
import * as api from "../api"
export let table
let smartSchemaRow
let fields = Object.keys(table.schema).map(field => ({
name: field,
type: table.schema[field].type.toUpperCase(),
}))
$: {
const schema = {}
for (let field of fields) {
if (!field.name) continue
schema[field.name] = FIELDS[field.type]
}
table.schema = schema
}
function newField() {
fields = [...fields, {}]
}
function deleteField(idx) {
fields.splice(idx, 1)
fields = fields
}
async function smartSchema() {
try {
const rows = await api.fetchDataForView($backendUiStore.selectedView)
const first = rows[0]
smartSchemaRow = first
fields = Object.keys(first).map(key => ({
// TODO: Smarter type mapping
name: key,
type: "STRING",
}))
} catch (err) {
notifier.danger("Error determining schema. Please enter fields manually.")
}
}
</script>
<section>
<div class="config">
<h6>Schema</h6>
{#if smartSchemaRow}
<pre>{JSON.stringify(smartSchemaRow, undefined, 2)}</pre>
{/if}
{#each fields as field, idx}
<div class="field">
<Input thin type={'text'} bind:value={field.name} />
<Select secondary thin bind:value={field.type}>
<option value={''}>Select an option</option>
<option value={'STRING'}>Text</option>
<option value={'NUMBER'}>Number</option>
<option value={'BOOLEAN'}>Boolean</option>
<option value={'DATETIME'}>Datetime</option>
</Select>
<i
class="ri-close-circle-line delete"
on:click={() => deleteField(idx)} />
</div>
{/each}
<Button thin secondary on:click={newField}>Add Field</Button>
<Button thin primary on:click={smartSchema}>Smart Schema</Button>
</div>
<div class="config">
<h6>Datasource</h6>
{#each Object.keys(table.integration) as configKey}
{#if configKey === 'query'}
<TextArea
thin
label={configKey}
bind:value={table.integration[configKey]} />
{:else}
<Input
thin
type={configKey.type}
label={configKey}
bind:value={table.integration[configKey]} />
{/if}
<Spacer small />
{/each}
</div>
</section>
<style>
.field {
display: grid;
grid-gap: 10px;
grid-template-columns: 1fr 1fr 50px;
margin-bottom: var(--spacing-m);
}
h6 {
font-family: var(--font-sans);
font-weight: 600;
text-rendering: var(--text-render);
color: var(--ink);
font-size: var(--heading-font-size-xs);
color: var(--ink);
margin-bottom: var(--spacing-m);
margin-top: var(--spacing-l);
}
.config {
margin-bottom: var(--spacing-s);
}
.delete {
align-self: center;
cursor: pointer;
}
</style>

View File

@ -1,5 +1,5 @@
<script>
import { goto, params } from "@sveltech/routify"
import { goto } from "@sveltech/routify"
import { backendUiStore, store } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import { Input, Label, ModalContent, Button, Spacer } from "@budibase/bbui"
@ -21,7 +21,7 @@
let dataImport
let integration
let error = ""
let externalDataSource = false
let createAutoscreens = true
function checkValid(evt) {
const tableName = evt.target.value
@ -50,6 +50,7 @@
analytics.captureEvent("Table Created", { name })
// Create auto screens
if (createAutoscreens) {
const screens = screenTemplates($store, [table])
.filter(template => defaultScreens.includes(template.id))
.map(template => template.create())
@ -67,6 +68,7 @@
listScreen.routing.route,
table.name
)
}
// Navigate to new table
$goto(`./table/${table._id}`)
@ -85,6 +87,9 @@
on:input={checkValid}
bind:value={name}
{error} />
<Toggle
text="Generate screens in the design section"
bind:checked={createAutoscreens} />
<div>
<Label grey extraSmall>Create Table from CSV (Optional)</Label>
<TableDataImport bind:dataImport />

View File

@ -6,6 +6,7 @@
import api from "builderStore/api"
import { notifier } from "builderStore/store/notifications"
import CreateWebhookDeploymentModal from "./CreateWebhookDeploymentModal.svelte"
import { hostingStore } from "builderStore"
const DeploymentStatus = {
SUCCESS: "SUCCESS",
@ -35,7 +36,7 @@
let errorReason
let poll
let deployments = []
let deploymentUrl = `https://${appId}.app.budi.live/${appId}`
let deploymentUrl = `${$hostingStore.appUrl}/${appId}`
const formatDate = (date, format) =>
Intl.DateTimeFormat("en-GB", DATE_OPTIONS[format]).format(date)
@ -95,9 +96,7 @@
<h4>Deployment History</h4>
<div class="deploy-div">
{#if deployments.some(deployment => deployment.status === DeploymentStatus.SUCCESS)}
<a target="_blank" href={`https://${appId}.app.budi.live/${appId}`}>
View Your Deployed App →
</a>
<a target="_blank" href={deploymentUrl}> View Your Deployed App </a>
<Button primary on:click={() => modal.show()}>View webhooks</Button>
{/if}
</div>

View File

@ -1,20 +1,12 @@
<script>
import { themeStore } from "builderStore"
import { Label, DropdownMenu, Toggle, Button, Slider } from "@budibase/bbui"
import { Label, Toggle, Button, Slider } from "@budibase/bbui"
let anchor
let popover
let showAdvanced = false
</script>
<div class="topnavitemright" on:click={popover.show} bind:this={anchor}>
<i class="ri-paint-fill" />
</div>
<div class="dropdown">
<DropdownMenu bind:this={popover} {anchor} align="right">
<div class="content">
<div class="content">
<div>
<Label extraSmall grey>Theme</Label>
<Toggle thin text="Dark theme" bind:checked={$themeStore.darkMode} />
</div>
{#if $themeStore.darkMode && !showAdvanced}
@ -45,38 +37,10 @@
<Button text on:click={themeStore.reset}>Reset</Button>
</div>
{/if}
</div>
</DropdownMenu>
</div>
<style>
.dropdown {
z-index: 2;
}
i {
font-size: 18px;
color: var(--grey-7);
}
.topnavitemright {
cursor: pointer;
color: var(--grey-7);
margin: 0 12px 0 0;
font-weight: 500;
font-size: 1rem;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
height: 24px;
width: 24px;
}
.topnavitemright:hover i {
color: var(--ink);
}
.content {
padding: var(--spacing-xl);
display: flex;
flex-direction: column;
justify-content: flex-start;
@ -84,11 +48,6 @@
gap: var(--spacing-l);
}
h5 {
margin: 0;
font-weight: 500;
}
.button {
align-self: flex-start;
}

View File

@ -0,0 +1,55 @@
<script>
import { Label, DropdownMenu } from "@budibase/bbui"
import ThemeEditor from "./ThemeEditor.svelte"
let anchor
let popover
</script>
<div class="topnavitemright" on:click={popover.show} bind:this={anchor}>
<i class="ri-paint-fill" />
</div>
<div class="dropdown">
<DropdownMenu bind:this={popover} {anchor} align="right">
<div class="content">
<Label extraSmall grey>Theme</Label>
<ThemeEditor />
</div>
</DropdownMenu>
</div>
<style>
.dropdown {
z-index: 2;
}
i {
font-size: 18px;
color: var(--grey-7);
}
.topnavitemright {
cursor: pointer;
color: var(--grey-7);
margin: 0 12px 0 0;
font-weight: 500;
font-size: 1rem;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
height: 24px;
width: 24px;
}
.topnavitemright:hover i {
color: var(--ink);
}
h5 {
margin: 0;
font-weight: 500;
}
.content {
padding: var(--spacing-xl);
}
</style>

View File

@ -0,0 +1,32 @@
<script>
import { TextButton as Button, Modal } from "@budibase/bbui"
import BuilderSettingsModal from "./BuilderSettingsModal.svelte"
let modal
</script>
<div>
<Button text on:click={modal.show}>
<i class="ri-settings-3-fill" />
<p>Settings</p>
</Button>
</div>
<Modal bind:this={modal} width="30%">
<BuilderSettingsModal />
</Modal>
<style>
div i {
font-size: 26px;
color: var(--grey-7);
margin-left: 12px;
}
div p {
font-family: var(--font-sans);
font-size: var(--font-size-s);
color: var(--ink);
font-weight: 400;
margin: 0 0 0 12px;
}
</style>

View File

@ -0,0 +1,72 @@
<script>
import { notifier } from "builderStore/store/notifications"
import { hostingStore } from "builderStore"
import { Input, ModalContent, Toggle } from "@budibase/bbui"
import ThemeEditor from "components/settings/ThemeEditor.svelte"
import analytics from "analytics"
import { onMount } from "svelte"
let hostingInfo
let selfhosted = false
async function save() {
hostingInfo.type = selfhosted ? "self" : "cloud"
if (!selfhosted && hostingInfo._rev) {
hostingInfo = {
type: hostingInfo.type,
_id: hostingInfo._id,
_rev: hostingInfo._rev,
}
}
try {
await hostingStore.actions.save(hostingInfo)
notifier.success(`Settings saved.`)
} catch (err) {
notifier.danger(`Failed to update builder settings.`)
}
}
function updateSelfHosting(event) {
if (hostingInfo.type === "cloud" && event.target.checked) {
hostingInfo.hostingUrl = "localhost:10000"
hostingInfo.useHttps = false
hostingInfo.selfHostKey = "budibase"
}
}
onMount(async () => {
hostingInfo = await hostingStore.actions.fetch()
selfhosted = hostingInfo.type === "self"
})
</script>
<ModalContent title="Builder settings" confirmText="Save" onConfirm={save}>
<h5>Theme</h5>
<ThemeEditor />
<h5>Hosting</h5>
<p>
This section contains settings that relate to the deployment and hosting of
apps made in this builder.
</p>
<Toggle
thin
text="Self hosted"
on:change={updateSelfHosting}
bind:checked={selfhosted} />
{#if selfhosted}
<Input bind:value={hostingInfo.hostingUrl} label="Hosting URL" />
<Input bind:value={hostingInfo.selfHostKey} label="Hosting Key" />
<Toggle thin text="HTTPS" bind:checked={hostingInfo.useHttps} />
{/if}
</ModalContent>
<style>
h5 {
margin: 0;
font-size: 14px;
}
p {
margin: 0;
font-size: 12px;
}
</style>

View File

@ -1,6 +1,11 @@
<script>
import { writable } from "svelte/store"
import { store, automationStore, backendUiStore } from "builderStore"
import {
store,
automationStore,
backendUiStore,
hostingStore,
} from "builderStore"
import { string, object } from "yup"
import api, { get } from "builderStore/api"
import Form from "@svelteschool/svelte-forms"
@ -12,6 +17,7 @@
import { fade } from "svelte/transition"
import { post } from "builderStore/api"
import analytics from "analytics"
import { onMount } from "svelte"
//Move this to context="module" once svelte-forms is updated so that it can bind to stores correctly
const createAppStore = writable({ currentStep: 0, values: {} })
@ -23,6 +29,7 @@
let lastApiKey
let fetchApiKeyPromise
const validateApiKey = async apiKey => {
if (isApiKeyValid) return true
if (!apiKey) return false
// make sure we only fetch once, unless API Key is changed
@ -39,43 +46,46 @@
return isApiKeyValid
}
let submitting = false
let errors = {}
let validationErrors = {}
let validationSchemas = [
{
const apiValidation = {
apiKey: string()
.required("Please enter your API key.")
.test("valid-apikey", "This API key is invalid", validateApiKey),
},
{
}
const infoValidation = {
applicationName: string().required("Your application must have a name."),
},
{
}
const userValidation = {
email: string()
.email()
.required("Your application needs a first user."),
password: string().required(
"Please enter a password for your first user."
),
password: string().required("Please enter a password for your first user."),
roleId: string().required("You need to select a role for your user."),
},
]
}
let steps = [
{
component: API,
let submitting = false
let errors = {}
let validationErrors = {}
let validationSchemas = [apiValidation, infoValidation, userValidation]
function buildStep(component) {
return {
component,
errors,
},
{
component: Info,
errors,
},
{
component: User,
errors,
},
]
}
}
// steps need to be initialized for cypress from the get go
let steps = [buildStep(API), buildStep(Info), buildStep(User)]
onMount(async () => {
let hostingInfo = await hostingStore.actions.fetch()
// re-init the steps based on whether self hosting or cloud hosted
if (hostingInfo.type === "self") {
isApiKeyValid = true
steps = [buildStep(Info), buildStep(User)]
validationSchemas = [infoValidation, userValidation]
}
})
if (hasKey) {
validationSchemas.shift()

View File

@ -30,6 +30,7 @@
}
$: selectedComponentId = $store.selectedComponentId ?? ""
$: previewData = {
appId: $store.appId,
layout,
screen,
selectedComponentId,

View File

@ -22,10 +22,11 @@
}
// Extract data from message
const { selectedComponentId, layout, screen, previewType } = JSON.parse(event.data)
const { selectedComponentId, layout, screen, previewType, appId } = JSON.parse(event.data)
// Set some flags so the app knows we're in the builder
window["##BUDIBASE_IN_BUILDER##"] = true
window["##BUDIBASE_APP_ID##"] = appId
window["##BUDIBASE_PREVIEW_LAYOUT##"] = layout
window["##BUDIBASE_PREVIEW_SCREEN##"] = screen
window["##BUDIBASE_SELECTED_COMPONENT_ID##"] = selectedComponentId

View File

@ -20,8 +20,6 @@
let getCaretPosition
$: console.log(bindings)
$: categories = Object.entries(groupBy("category", bindings))
function onClickBinding(binding) {

View File

@ -2,7 +2,7 @@
import { store, automationStore, backendUiStore } from "builderStore"
import { Button } from "@budibase/bbui"
import SettingsLink from "components/settings/Link.svelte"
import ThemeEditor from "components/settings/ThemeEditor.svelte"
import ThemeEditorDropdown from "components/settings/ThemeEditorDropdown.svelte"
import FeedbackNavLink from "components/userInterface/Feedback/FeedbackNavLink.svelte"
import { get } from "builderStore/api"
import { isActive, goto, layout } from "@sveltech/routify"
@ -67,7 +67,7 @@
{/each}
</div>
<div class="toprightnav">
<ThemeEditor />
<ThemeEditorDropdown />
<FeedbackNavLink />
<div class="topnavitemright">
<a

View File

@ -7,6 +7,9 @@
CommunityIcon,
BugIcon,
} from "components/common/Icons"
import BuilderSettingsButton from "components/start/BuilderSettingsButton.svelte"
let modal
</script>
<div class="root">
@ -16,6 +19,7 @@
</div>
<div class="nav-section">
<div class="nav-top">
<Link icon={AppsIcon} title="Apps" href="/" active />
<Link
icon={HostingIcon}
@ -29,14 +33,16 @@
icon={CommunityIcon}
title="Community"
href="https://github.com/Budibase/budibase/discussions" />
<Link
icon={BugIcon}
title="Raise an issue"
href="https://github.com/Budibase/budibase/issues/new/choose" />
</div>
<div class="nav-bottom">
<BuilderSettingsButton />
</div>
</div>
</div>
<div class="main">
<slot />
</div>
@ -76,8 +82,10 @@
}
.nav-section {
margin: 20px 0px;
margin: 20px 0 0 0;
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
}
</style>

View File

@ -10,7 +10,7 @@ export default {
output: [
{
sourcemap: true,
format: "esm",
format: "iife",
file: `./dist/budibase-client.js`,
},
],

View File

@ -1,17 +1,8 @@
import { getAppId } from "../utils/getAppId"
/**
* API cache for cached request responses.
*/
let cache = {}
/**
* Makes a fully formatted URL based on the SDK configuration.
*/
const makeFullURL = path => {
return `/${path}`.replace("//", "/")
}
/**
* Handler for API errors.
*/
@ -29,7 +20,7 @@ const makeApiCall = async ({ method, url, body, json = true }) => {
let headers = {
Accept: "application/json",
"Content-Type": "application/json",
"x-budibase-app-id": getAppId(),
"x-budibase-app-id": window["##BUDIBASE_APP_ID##"],
}
if (!window["##BUDIBASE_IN_BUILDER##"]) {
headers["x-budibase-type"] = "client"
@ -82,8 +73,8 @@ const makeCachedApiCall = async params => {
*/
const requestApiCall = method => async params => {
const { url, cache = false } = params
const fullURL = makeFullURL(url)
const enrichedParams = { ...params, method, url: fullURL }
const fixedUrl = `/${url}`.replace("//", "/")
const enrichedParams = { ...params, method, fixedUrl }
return await (cache ? makeCachedApiCall : makeApiCall)(enrichedParams)
}

View File

@ -7,6 +7,7 @@ const loadBudibase = () => {
// Update builder store with any builder flags
builderStore.set({
inBuilder: !!window["##BUDIBASE_IN_BUILDER##"],
appId: window["##BUDIBASE_APP_ID##"],
layout: window["##BUDIBASE_PREVIEW_LAYOUT##"],
screen: window["##BUDIBASE_PREVIEW_SCREEN##"],
selectedComponentId: window["##BUDIBASE_SELECTED_COMPONENT_ID##"],

View File

@ -2,7 +2,6 @@ import * as API from "./api"
import { authStore, routeStore, screenStore, bindingStore } from "./store"
import { styleable } from "./utils/styleable"
import { linkable } from "./utils/linkable"
import { getAppId } from "./utils/getAppId"
import DataProvider from "./components/DataProvider.svelte"
export default {
@ -12,7 +11,6 @@ export default {
screenStore,
styleable,
linkable,
getAppId,
DataProvider,
setBindableValue: bindingStore.actions.setBindableValue,
}

View File

@ -1,8 +1,8 @@
import * as API from "../api"
import { getAppId } from "../utils/getAppId"
import { writable } from "svelte/store"
import { writable, get } from "svelte/store"
import { initialise } from "./initialise"
import { routeStore } from "./routes"
import { builderStore } from "./builder"
const createAuthStore = () => {
const store = writable("")
@ -25,7 +25,7 @@ const createAuthStore = () => {
}
const logOut = async () => {
store.set("")
const appId = getAppId()
const appId = get(builderStore).appId
if (appId) {
for (let environment of ["local", "cloud"]) {
window.document.cookie = `budibase:${appId}:${environment}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;`

View File

@ -3,6 +3,7 @@ import { writable } from "svelte/store"
const createBuilderStore = () => {
const initialState = {
inBuilder: false,
appId: null,
layout: null,
screen: null,
selectedComponentId: null,

View File

@ -1,8 +1,7 @@
import { writable, derived } from "svelte/store"
import { writable, derived, get } from "svelte/store"
import { routeStore } from "./routes"
import { builderStore } from "./builder"
import * as API from "../api"
import { getAppId } from "../utils/getAppId"
const createScreenStore = () => {
const config = writable({
@ -40,7 +39,7 @@ const createScreenStore = () => {
)
const fetchScreens = async () => {
const appDefinition = await API.fetchAppDefinition(getAppId())
const appDefinition = await API.fetchAppDefinition(get(builderStore).appId)
config.set({
screens: appDefinition.screens,
layouts: appDefinition.layouts,

View File

@ -1,47 +0,0 @@
const COOKIE_SEPARATOR = ";"
const APP_PREFIX = "app_"
const KEY_VALUE_SPLIT = "="
function confirmAppId(possibleAppId) {
return possibleAppId && possibleAppId.startsWith(APP_PREFIX)
? possibleAppId
: undefined
}
function tryGetFromCookie({ cookies }) {
if (!cookies) {
return undefined
}
const cookie = cookies
.split(COOKIE_SEPARATOR)
.find(cookie => cookie.trim().startsWith("budibase:currentapp"))
let appId
if (cookie && cookie.split(KEY_VALUE_SPLIT).length === 2) {
appId = cookie.split("=")[1]
}
return confirmAppId(appId)
}
function tryGetFromPath() {
const appId = location.pathname.split("/")[1]
return confirmAppId(appId)
}
function tryGetFromSubdomain() {
const parts = window.location.host.split(".")
const appId = parts[1] ? parts[0] : undefined
return confirmAppId(appId)
}
export const getAppId = (cookies = window.document.cookie) => {
const functions = [tryGetFromSubdomain, tryGetFromPath, tryGetFromCookie]
// try getting the app Id in order
let appId
for (let func of functions) {
appId = func({ cookies })
if (appId) {
break
}
}
return appId
}

View File

@ -36,7 +36,7 @@ const addBuilderPreviewStyles = (styleString, componentId, selectable) => {
// Highlighted selected element
if (componentId === state.selectedComponentId) {
str += `;box-shadow: 0 0 0 ${selectedComponentWidth}px ${selectedComponentColor} inset !important;`
str += `;border: ${selectedComponentWidth}px solid ${selectedComponentColor} !important;`
}
}

View File

@ -4,7 +4,7 @@ WORKDIR /app
ENV CLOUD=1
ENV COUCH_DB_URL=https://couchdb.budi.live:5984
env BUDIBASE_ENVIRONMENT=PRODUCTION
ENV BUDIBASE_ENVIRONMENT=PRODUCTION
# copy files and install dependencies
COPY . ./
@ -12,4 +12,8 @@ RUN yarn
EXPOSE 4001
# have to add node environment production after install
# due to this causing yarn to stop installing dev dependencies
# which are actually needed to get this environment up and running
ENV NODE_ENV=production
CMD ["yarn", "run:docker"]

View File

@ -1,56 +0,0 @@
// THIS will create API Keys and App Ids input in a local Dynamo instance if it is running
const dynamoClient = require("../src/db/dynamoClient")
const env = require("../src/environment")
if (process.argv[2] == null || process.argv[3] == null) {
console.error(
"Inputs incorrect format, was expecting: node createApiKeyAndAppId.js <API_KEY> <APP_ID>"
)
process.exit(-1)
}
const FAKE_STRING = "fakestring"
// set fake credentials for local dynamo to actually work
env._set("AWS_ACCESS_KEY_ID", "KEY_ID")
env._set("AWS_SECRET_ACCESS_KEY", "SECRET_KEY")
dynamoClient.init("http://localhost:8333")
async function run() {
await dynamoClient.apiKeyTable.put({
item: {
pk: process.argv[2],
accountId: FAKE_STRING,
trackingId: FAKE_STRING,
quotaReset: Date.now() + 2592000000,
usageQuota: {
automationRuns: 0,
rows: 0,
storage: 0,
users: 0,
views: 0,
},
usageLimits: {
automationRuns: 10,
rows: 10,
storage: 1000,
users: 10,
views: 10,
},
},
})
await dynamoClient.apiKeyTable.put({
item: {
pk: process.argv[3],
apiKey: process.argv[2],
},
})
}
run()
.then(() => {
console.log("Rows should have been created.")
})
.catch(err => {
console.error("Cannot create rows - " + err)
})

View File

@ -3,14 +3,21 @@ const { join } = require("../../utilities/centralPath")
const readline = require("readline")
const { budibaseAppsDir } = require("../../utilities/budibaseDir")
const env = require("../../environment")
const selfhost = require("../../selfhost")
const ENV_FILE_PATH = "/.env"
exports.fetch = async function(ctx) {
ctx.status = 200
if (env.SELF_HOSTED) {
ctx.body = {
selfhost: await selfhost.getSelfHostAPIKey(),
}
} else {
ctx.body = {
budibase: env.BUDIBASE_API_KEY,
userId: env.USERID_API_KEY,
}
}
}
exports.update = async function(ctx) {

View File

@ -150,6 +150,9 @@ exports.create = async function(ctx) {
name: ctx.request.body.name,
template: ctx.request.body.template,
instance: instance,
deployment: {
type: "cloud",
},
}
const instanceDb = new CouchDB(appId)
await instanceDb.put(newApplication)

View File

@ -35,8 +35,8 @@ exports.authenticate = async ctx => {
roleId: dbUser.roleId,
version: app.version,
}
// if in cloud add the user api key
if (env.CLOUD) {
// if in cloud add the user api key, unless self hosted
if (env.CLOUD && !env.SELF_HOSTED) {
const { apiKey } = await getAPIKey(ctx.user.appId)
payload.apiKey = apiKey
}

View File

@ -0,0 +1,88 @@
const { getAppQuota } = require("./quota")
const env = require("../../../environment")
const newid = require("../../../db/newid")
/**
* This is used to pass around information about the deployment that is occurring
*/
class Deployment {
constructor(appId, id = null) {
this.appId = appId
this._id = id || newid()
}
// purely so that we can do quota stuff outside the main deployment context
async init() {
if (!env.SELF_HOSTED) {
this.setQuota(await getAppQuota(this.appId))
}
}
setQuota(quota) {
if (!quota) {
return
}
this.quota = quota
}
getQuota() {
return this.quota
}
getAppId() {
return this.appId
}
setVerification(verification) {
if (!verification) {
return
}
this.verification = verification
if (this.verification.quota) {
this.quota = this.verification.quota
}
}
getVerification() {
return this.verification
}
setStatus(status, err = null) {
this.status = status
if (err) {
this.err = err
}
}
fromJSON(json) {
if (json.verification) {
this.setVerification(json.verification)
}
if (json.quota) {
this.setQuota(json.quota)
}
if (json.status) {
this.setStatus(json.status, json.err)
}
}
getJSON() {
const obj = {
_id: this._id,
appId: this.appId,
status: this.status,
}
if (this.err) {
obj.err = this.err
}
if (this.verification && this.verification.cfDistribution) {
obj.cfDistribution = this.verification.cfDistribution
}
if (this.quota) {
obj.quota = this.quota
}
return obj
}
}
module.exports = Deployment

View File

@ -1,189 +0,0 @@
const fs = require("fs")
const { join } = require("../../../utilities/centralPath")
const AWS = require("aws-sdk")
const fetch = require("node-fetch")
const sanitize = require("sanitize-s3-objectkey")
const { budibaseAppsDir } = require("../../../utilities/budibaseDir")
const PouchDB = require("../../../db")
const env = require("../../../environment")
/**
* Finalises the deployment, updating the quota for the user API key
* The verification process returns the levels to update to.
* Calls the "deployment-success" lambda.
* @param {object} quota The usage quota levels returned from the verifyDeploy
* @returns {Promise<object>} The usage has been updated against the user API key.
*/
exports.updateDeploymentQuota = async function(quota) {
const DEPLOYMENT_SUCCESS_URL =
env.DEPLOYMENT_CREDENTIALS_URL + "deploy/success"
const response = await fetch(DEPLOYMENT_SUCCESS_URL, {
method: "POST",
body: JSON.stringify({
apiKey: env.BUDIBASE_API_KEY,
quota,
}),
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
})
if (response.status !== 200) {
throw new Error(`Error updating deployment quota for API Key`)
}
return await response.json()
}
/**
* Verifies the users API key and
* Verifies that the deployment fits within the quota of the user
* Links to the "check-api-key" lambda.
* @param {String} appId - appId being deployed
* @param {String} appId - appId being deployed
* @param {quota} quota - current quota being changed with this application
*/
exports.verifyDeployment = async function({ appId, quota }) {
const response = await fetch(env.DEPLOYMENT_CREDENTIALS_URL, {
method: "POST",
body: JSON.stringify({
apiKey: env.BUDIBASE_API_KEY,
appId,
quota,
}),
})
const json = await response.json()
if (json.errors) {
throw new Error(json.errors)
}
if (response.status !== 200) {
throw new Error(
`Error fetching temporary credentials for api key: ${env.BUDIBASE_API_KEY}`
)
}
// set credentials here, means any time we're verified we're ready to go
if (json.credentials) {
AWS.config.update({
accessKeyId: json.credentials.AccessKeyId,
secretAccessKey: json.credentials.SecretAccessKey,
sessionToken: json.credentials.SessionToken,
})
}
return json
}
const CONTENT_TYPE_MAP = {
html: "text/html",
css: "text/css",
js: "application/javascript",
}
/**
* Recursively walk a directory tree and execute a callback on all files.
* @param {String} dirPath - Directory to traverse
* @param {Function} callback - callback to execute on files
*/
function walkDir(dirPath, callback) {
for (let filename of fs.readdirSync(dirPath)) {
const filePath = `${dirPath}/${filename}`
const stat = fs.lstatSync(filePath)
if (stat.isFile()) {
callback(filePath)
} else {
walkDir(filePath, callback)
}
}
}
async function prepareUploadForS3({ s3Key, metadata, s3, file }) {
const extension = [...file.name.split(".")].pop()
const fileBytes = fs.readFileSync(file.path)
const upload = await s3
.upload({
// windows filepaths need to be converted to forward slashes for s3
Key: sanitize(s3Key).replace(/\\/g, "/"),
Body: fileBytes,
ContentType: file.type || CONTENT_TYPE_MAP[extension.toLowerCase()],
Metadata: metadata,
})
.promise()
return {
size: file.size,
name: file.name,
extension,
url: upload.Location,
key: upload.Key,
}
}
exports.prepareUploadForS3 = prepareUploadForS3
exports.uploadAppAssets = async function({ appId, bucket, accountId }) {
const s3 = new AWS.S3({
params: {
Bucket: bucket,
},
})
const appAssetsPath = join(budibaseAppsDir(), appId, "public")
let uploads = []
// Upload HTML and JS of the web app
walkDir(appAssetsPath, function(filePath) {
const filePathParts = filePath.split("/")
const appAssetUpload = prepareUploadForS3({
file: {
path: filePath,
name: filePathParts.pop(),
},
s3Key: filePath.replace(appAssetsPath, `assets/${appId}`),
s3,
metadata: { accountId },
})
uploads.push(appAssetUpload)
})
// Upload file attachments
const db = new PouchDB(appId)
let fileUploads
try {
fileUploads = await db.get("_local/fileuploads")
} catch (err) {
fileUploads = { _id: "_local/fileuploads", uploads: [] }
}
for (let file of fileUploads.uploads) {
if (file.uploaded) continue
const attachmentUpload = prepareUploadForS3({
file,
s3Key: `assets/${appId}/attachments/${file.processedFileName}`,
s3,
metadata: { accountId },
})
uploads.push(attachmentUpload)
// mark file as uploaded
file.uploaded = true
}
db.put(fileUploads)
try {
return await Promise.all(uploads)
} catch (err) {
console.error("Error uploading budibase app assets to s3", err)
throw err
}
}

View File

@ -0,0 +1,85 @@
const AWS = require("aws-sdk")
const fetch = require("node-fetch")
const env = require("../../../environment")
const {
deployToObjectStore,
performReplication,
fetchCredentials,
} = require("./utils")
/**
* Verifies the users API key and
* Verifies that the deployment fits within the quota of the user
* Links to the "check-api-key" lambda.
* @param {object} deployment - information about the active deployment, including the appId and quota.
*/
exports.preDeployment = async function(deployment) {
const json = await fetchCredentials(env.DEPLOYMENT_CREDENTIALS_URL, {
apiKey: env.BUDIBASE_API_KEY,
appId: deployment.getAppId(),
quota: deployment.getQuota(),
})
// set credentials here, means any time we're verified we're ready to go
if (json.credentials) {
AWS.config.update({
accessKeyId: json.credentials.AccessKeyId,
secretAccessKey: json.credentials.SecretAccessKey,
sessionToken: json.credentials.SessionToken,
})
}
return json
}
/**
* Finalises the deployment, updating the quota for the user API key
* The verification process returns the levels to update to.
* Calls the "deployment-success" lambda.
* @param {object} deployment information about the active deployment, including the quota info.
* @returns {Promise<object>} The usage has been updated against the user API key.
*/
exports.postDeployment = async function(deployment) {
const DEPLOYMENT_SUCCESS_URL =
env.DEPLOYMENT_CREDENTIALS_URL + "deploy/success"
const response = await fetch(DEPLOYMENT_SUCCESS_URL, {
method: "POST",
body: JSON.stringify({
apiKey: env.BUDIBASE_API_KEY,
quota: deployment.getQuota(),
}),
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
})
if (response.status !== 200) {
throw new Error(`Error updating deployment quota for API Key`)
}
return await response.json()
}
exports.deploy = async function(deployment) {
const appId = deployment.getAppId()
const { bucket, accountId } = deployment.getVerification()
const metadata = { accountId }
const s3Client = new AWS.S3({
params: {
Bucket: bucket,
},
})
await deployToObjectStore(appId, s3Client, metadata)
}
exports.replicateDb = async function(deployment) {
const appId = deployment.getAppId()
const verification = deployment.getVerification()
return performReplication(
appId,
verification.couchDbSession,
env.DEPLOYMENT_DB_URL
)
}

View File

@ -1,23 +1,20 @@
const CouchDB = require("pouchdb")
const PouchDB = require("../../../db")
const Deployment = require("./Deployment")
const {
uploadAppAssets,
verifyDeployment,
updateDeploymentQuota,
} = require("./aws")
const { DocumentTypes, SEPARATOR, UNICODE_MAX } = require("../../../db/utils")
const newid = require("../../../db/newid")
const env = require("../../../environment")
getHostingInfo,
HostingTypes,
} = require("../../../utilities/builder/hosting")
// the max time we can wait for an invalidation to complete before considering it failed
const MAX_PENDING_TIME_MS = 30 * 60000
const DeploymentStatus = {
SUCCESS: "SUCCESS",
PENDING: "PENDING",
FAILURE: "FAILURE",
}
// default to AWS deployment, this will be updated before use (if required)
let deploymentService = require("./awsDeploy")
// checks that deployments are in a good state, any pending will be updated
async function checkAllDeployments(deployments) {
let updated = false
@ -27,7 +24,7 @@ async function checkAllDeployments(deployments) {
deployment.status === DeploymentStatus.PENDING &&
Date.now() - deployment.updatedAt > MAX_PENDING_TIME_MS
) {
deployment.status = status
deployment.status = DeploymentStatus.FAILURE
deployment.err = "Timed out"
updated = true
}
@ -35,54 +32,10 @@ async function checkAllDeployments(deployments) {
return { updated, deployments }
}
function replicate(local, remote) {
return new Promise((resolve, reject) => {
const replication = local.sync(remote)
replication.on("complete", () => resolve())
replication.on("error", err => reject(err))
})
}
async function replicateCouch({ appId, session }) {
const localDb = new PouchDB(appId)
const remoteDb = new CouchDB(`${env.DEPLOYMENT_DB_URL}/${appId}`, {
fetch: function(url, opts) {
opts.headers.set("Cookie", `${session};`)
return CouchDB.fetch(url, opts)
},
})
return replicate(localDb, remoteDb)
}
async function getCurrentInstanceQuota(appId) {
const db = new PouchDB(appId)
const rows = await db.allDocs({
startkey: DocumentTypes.ROW + SEPARATOR,
endkey: DocumentTypes.ROW + SEPARATOR + UNICODE_MAX,
})
const users = await db.allDocs({
startkey: DocumentTypes.USER + SEPARATOR,
endkey: DocumentTypes.USER + SEPARATOR + UNICODE_MAX,
})
const existingRows = rows.rows.length
const existingUsers = users.rows.length
const designDoc = await db.get("_design/database")
return {
rows: existingRows,
users: existingUsers,
views: Object.keys(designDoc.views).length,
}
}
async function storeLocalDeploymentHistory(deployment) {
const db = new PouchDB(deployment.appId)
const appId = deployment.getAppId()
const deploymentJSON = deployment.getJSON()
const db = new PouchDB(appId)
let deploymentDoc
try {
@ -91,7 +44,7 @@ async function storeLocalDeploymentHistory(deployment) {
deploymentDoc = { _id: "_local/deployments", history: {} }
}
const deploymentId = deployment._id || newid()
const deploymentId = deploymentJSON._id
// first time deployment
if (!deploymentDoc.history[deploymentId])
@ -99,56 +52,42 @@ async function storeLocalDeploymentHistory(deployment) {
deploymentDoc.history[deploymentId] = {
...deploymentDoc.history[deploymentId],
...deployment,
...deploymentJSON,
updatedAt: Date.now(),
}
await db.put(deploymentDoc)
return {
_id: deploymentId,
...deploymentDoc.history[deploymentId],
}
deployment.fromJSON(deploymentDoc.history[deploymentId])
return deployment
}
async function deployApp({ appId, deploymentId }) {
async function deployApp(deployment) {
const appId = deployment.getAppId()
try {
const instanceQuota = await getCurrentInstanceQuota(appId)
const verification = await verifyDeployment({
appId,
quota: instanceQuota,
})
await deployment.init()
deployment.setVerification(
await deploymentService.preDeployment(deployment)
)
console.log(`Uploading assets for appID ${appId} assets to s3..`)
console.log(`Uploading assets for appID ${appId}..`)
await uploadAppAssets({
appId,
...verification,
})
await deploymentService.deploy(deployment)
// replicate the DB to the couchDB cluster in prod
console.log("Replicating local PouchDB to remote..")
await replicateCouch({
appId,
session: verification.couchDbSession,
})
// replicate the DB to the main couchDB cluster
console.log("Replicating local PouchDB to CouchDB..")
await deploymentService.replicateDb(deployment)
await updateDeploymentQuota(verification.quota)
await deploymentService.postDeployment(deployment)
await storeLocalDeploymentHistory({
_id: deploymentId,
appId,
cfDistribution: verification.cfDistribution,
quota: verification.quota,
status: DeploymentStatus.SUCCESS,
})
deployment.setStatus(DeploymentStatus.SUCCESS)
await storeLocalDeploymentHistory(deployment)
} catch (err) {
await storeLocalDeploymentHistory({
_id: deploymentId,
appId,
status: DeploymentStatus.FAILURE,
err: err.message,
})
throw new Error(`Deployment Failed: ${err.message}`)
deployment.setStatus(DeploymentStatus.FAILURE, err.message)
await storeLocalDeploymentHistory(deployment)
throw {
...err,
message: `Deployment Failed: ${err.message}`,
}
}
}
@ -183,15 +122,17 @@ exports.deploymentProgress = async function(ctx) {
}
exports.deployApp = async function(ctx) {
const deployment = await storeLocalDeploymentHistory({
appId: ctx.user.appId,
status: DeploymentStatus.PENDING,
})
// start by checking whether to deploy local or to cloud
const hostingInfo = await getHostingInfo()
deploymentService =
hostingInfo.type === HostingTypes.CLOUD
? require("./awsDeploy")
: require("./selfDeploy")
let deployment = new Deployment(ctx.user.appId)
deployment.setStatus(DeploymentStatus.PENDING)
deployment = await storeLocalDeploymentHistory(deployment)
deployApp({
...ctx.user,
deploymentId: deployment._id,
})
await deployApp(deployment)
ctx.body = deployment
}

View File

@ -0,0 +1,27 @@
const PouchDB = require("../../../db")
const { DocumentTypes, SEPARATOR, UNICODE_MAX } = require("../../../db/utils")
exports.getAppQuota = async function(appId) {
const db = new PouchDB(appId)
const rows = await db.allDocs({
startkey: DocumentTypes.ROW + SEPARATOR,
endkey: DocumentTypes.ROW + SEPARATOR + UNICODE_MAX,
})
const users = await db.allDocs({
startkey: DocumentTypes.USER + SEPARATOR,
endkey: DocumentTypes.USER + SEPARATOR + UNICODE_MAX,
})
const existingRows = rows.rows.length
const existingUsers = users.rows.length
const designDoc = await db.get("_design/database")
return {
rows: existingRows,
users: existingUsers,
views: Object.keys(designDoc.views).length,
}
}

View File

@ -0,0 +1,69 @@
const AWS = require("aws-sdk")
const {
deployToObjectStore,
performReplication,
fetchCredentials,
} = require("./utils")
const {
getWorkerUrl,
getCouchUrl,
getMinioUrl,
getSelfHostKey,
} = require("../../../utilities/builder/hosting")
exports.preDeployment = async function() {
const url = `${await getWorkerUrl()}/api/deploy`
try {
const json = await fetchCredentials(url, {
selfHostKey: await getSelfHostKey(),
})
// response contains:
// couchDbSession, bucket, objectStoreSession
// set credentials here, means any time we're verified we're ready to go
if (json.objectStoreSession) {
AWS.config.update({
accessKeyId: json.objectStoreSession.accessKeyId,
secretAccessKey: json.objectStoreSession.secretAccessKey,
})
}
return json
} catch (err) {
throw {
message: "Unauthorised to deploy, check self hosting key",
status: 401,
}
}
}
exports.postDeployment = async function() {
// we don't actively need to do anything after deployment in self hosting
}
exports.deploy = async function(deployment) {
const appId = deployment.getAppId()
const verification = deployment.getVerification()
const objClient = new AWS.S3({
endpoint: await getMinioUrl(),
s3ForcePathStyle: true, // needed with minio?
signatureVersion: "v4",
params: {
Bucket: verification.bucket,
},
})
// no metadata, aws has account ID in metadata
const metadata = {}
await deployToObjectStore(appId, objClient, metadata)
}
exports.replicateDb = async function(deployment) {
const appId = deployment.getAppId()
const verification = deployment.getVerification()
return performReplication(
appId,
verification.couchDbSession,
await getCouchUrl()
)
}

View File

@ -0,0 +1,131 @@
const fs = require("fs")
const sanitize = require("sanitize-s3-objectkey")
const { walkDir } = require("../../../utilities")
const { join } = require("../../../utilities/centralPath")
const { budibaseAppsDir } = require("../../../utilities/budibaseDir")
const fetch = require("node-fetch")
const PouchDB = require("../../../db")
const CouchDB = require("pouchdb")
const CONTENT_TYPE_MAP = {
html: "text/html",
css: "text/css",
js: "application/javascript",
}
exports.fetchCredentials = async function(url, body) {
const response = await fetch(url, {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
})
const json = await response.json()
if (json.errors) {
throw new Error(json.errors)
}
if (response.status !== 200) {
throw new Error(
`Error fetching temporary credentials: ${JSON.stringify(json)}`
)
}
return json
}
exports.prepareUpload = async function({ s3Key, metadata, client, file }) {
const extension = [...file.name.split(".")].pop()
const fileBytes = fs.readFileSync(file.path)
const upload = await client
.upload({
// windows file paths need to be converted to forward slashes for s3
Key: sanitize(s3Key).replace(/\\/g, "/"),
Body: fileBytes,
ContentType: file.type || CONTENT_TYPE_MAP[extension.toLowerCase()],
Metadata: metadata,
})
.promise()
return {
size: file.size,
name: file.name,
extension,
url: upload.Location,
key: upload.Key,
}
}
exports.deployToObjectStore = async function(appId, objectClient, metadata) {
const appAssetsPath = join(budibaseAppsDir(), appId, "public")
let uploads = []
// Upload HTML, CSS and JS for each page of the web app
walkDir(appAssetsPath, function(filePath) {
const filePathParts = filePath.split("/")
const appAssetUpload = exports.prepareUpload({
file: {
path: filePath,
name: filePathParts.pop(),
},
s3Key: filePath.replace(appAssetsPath, `assets/${appId}`),
client: objectClient,
metadata,
})
uploads.push(appAssetUpload)
})
// Upload file attachments
const db = new PouchDB(appId)
let fileUploads
try {
fileUploads = await db.get("_local/fileuploads")
} catch (err) {
fileUploads = { _id: "_local/fileuploads", uploads: [] }
}
for (let file of fileUploads.uploads) {
if (file.uploaded) continue
const attachmentUpload = exports.prepareUpload({
file,
s3Key: `assets/${appId}/attachments/${file.processedFileName}`,
client: objectClient,
metadata,
})
uploads.push(attachmentUpload)
// mark file as uploaded
file.uploaded = true
}
db.put(fileUploads)
try {
return await Promise.all(uploads)
} catch (err) {
console.error("Error uploading budibase app assets to s3", err)
throw err
}
}
exports.performReplication = (appId, session, dbUrl) => {
return new Promise((resolve, reject) => {
const local = new PouchDB(appId)
const remote = new CouchDB(`${dbUrl}/${appId}`, {
fetch: function(url, opts) {
opts.headers.set("Cookie", `${session};`)
return CouchDB.fetch(url, opts)
},
})
const replication = local.sync(remote)
replication.on("complete", () => resolve())
replication.on("error", err => reject(err))
})
}

View File

@ -0,0 +1,39 @@
const CouchDB = require("../../db")
const { BUILDER_CONFIG_DB, HOSTING_DOC } = require("../../constants")
const {
getHostingInfo,
HostingTypes,
getAppUrl,
} = require("../../utilities/builder/hosting")
exports.fetchInfo = async ctx => {
ctx.body = {
types: Object.values(HostingTypes),
}
}
exports.save = async ctx => {
const db = new CouchDB(BUILDER_CONFIG_DB)
const { type } = ctx.request.body
if (type === HostingTypes.CLOUD && ctx.request.body._rev) {
ctx.body = await db.remove({
...ctx.request.body,
_id: HOSTING_DOC,
})
} else {
ctx.body = await db.put({
...ctx.request.body,
_id: HOSTING_DOC,
})
}
}
exports.fetch = async ctx => {
ctx.body = await getHostingInfo()
}
exports.fetchUrls = async ctx => {
ctx.body = {
app: await getAppUrl(ctx.appId),
}
}

View File

@ -6,7 +6,7 @@ const fetch = require("node-fetch")
const fs = require("fs-extra")
const uuid = require("uuid")
const AWS = require("aws-sdk")
const { prepareUploadForS3 } = require("../deploy/aws")
const { prepareUpload } = require("../deploy/utils")
const handlebars = require("handlebars")
const {
budibaseAppsDir,
@ -17,6 +17,15 @@ const setBuilderToken = require("../../../utilities/builder/setBuilderToken")
const fileProcessor = require("../../../utilities/fileProcessor")
const env = require("../../../environment")
function objectStoreUrl() {
if (env.SELF_HOSTED) {
// can use a relative url for this as all goes through the proxy (this is hosted in minio)
return `/app-assets/assets`
} else {
return "https://cdn.app.budi.live/assets"
}
}
// this was the version before we started versioning the component library
const COMP_LIB_BASE_APP_VERSION = "0.2.5"
@ -53,7 +62,7 @@ exports.uploadFile = async function(ctx) {
const fileExtension = [...file.name.split(".")].pop()
const processedFileName = `${uuid.v4()}.${fileExtension}`
return prepareUploadForS3({
return prepareUpload({
file,
s3Key: `assets/${ctx.user.appId}/attachments/${processedFileName}`,
s3,
@ -148,6 +157,7 @@ exports.serveApp = async function(ctx) {
title: appInfo.name,
production: env.CLOUD,
appId: ctx.params.appId,
objectStoreUrl: objectStoreUrl(),
})
const template = handlebars.compile(
@ -158,6 +168,7 @@ exports.serveApp = async function(ctx) {
head,
body: html,
style: css.code,
appId: ctx.params.appId,
})
}
@ -166,8 +177,9 @@ exports.serveAttachment = async function(ctx) {
const attachmentsPath = resolve(budibaseAppsDir(), appId, "attachments")
// Serve from CloudFront
// TODO: need to replace this with link to self hosted object store
if (env.CLOUD) {
const S3_URL = `https://cdn.app.budi.live/assets/${appId}/attachments/${ctx.file}`
const S3_URL = join(objectStoreUrl(), appId, "attachments", ctx.file)
const response = await fetch(S3_URL)
const body = await response.text()
ctx.set("Content-Type", response.headers.get("Content-Type"))
@ -213,7 +225,13 @@ exports.serveComponentLibrary = async function(ctx) {
componentLib += `-${COMP_LIB_BASE_APP_VERSION}`
}
const S3_URL = encodeURI(
`https://${appId}.app.budi.live/assets/${componentLib}/${ctx.query.library}/dist/index.js`
join(
objectStoreUrl(appId),
componentLib,
ctx.query.library,
"dist",
"index.js"
)
)
const response = await fetch(S3_URL)
const body = await response.text()
@ -222,5 +240,5 @@ exports.serveComponentLibrary = async function(ctx) {
return
}
await send(ctx, "/index.js", { root: componentLibraryPath })
await send(ctx, "/awsDeploy.js", { root: componentLibraryPath })
}

View File

@ -4,12 +4,11 @@
export let appId
export let production
export const PRODUCTION_ASSETS_URL = `https://${appId}.app.budi.live`
export let objectStoreUrl
function publicPath(path) {
if (production) {
return `${PRODUCTION_ASSETS_URL}/assets/${appId}/${path}`
return `${objectStoreUrl}/${appId}/${path}`
}
return `/assets/${path}`

View File

@ -5,6 +5,9 @@
{{{head}}}
</head>
{{{body}}}
<script>
window["##BUDIBASE_APP_ID##"] = "{{appId}}"
</script>
{{{body}}}
</html>

View File

@ -17,26 +17,6 @@ router
datasourceController.find
)
.post("/api/datasources", authorized(BUILDER), datasourceController.save)
// .post(
// "/api/datasources/:datasourceId/queries",
// authorized(BUILDER),
// datasourceController.saveQuery
// )
// .post(
// "/api/datasources/queries/preview",
// authorized(BUILDER),
// datasourceController.previewQuery
// )
// .get(
// "/api/datasources/:datasourceId/queries/:queryId",
// authorized(BUILDER),
// datasourceController.fetchQuery
// )
// .post(
// "/api/datasources/:datasourceId/queries/:queryId",
// authorized(BUILDER),
// datasourceController.executeQuery
// )
.delete(
"/api/datasources/:datasourceId/:revId",
authorized(BUILDER),

View File

@ -0,0 +1,14 @@
const Router = require("@koa/router")
const controller = require("../controllers/hosting")
const authorized = require("../../middleware/authorized")
const { BUILDER } = require("../../utilities/security/permissions")
const router = Router()
router
.get("/api/hosting/info", authorized(BUILDER), controller.fetchInfo)
.get("/api/hosting/urls", authorized(BUILDER), controller.fetchUrls)
.get("/api/hosting", authorized(BUILDER), controller.fetch)
.post("/api/hosting", authorized(BUILDER), controller.save)
module.exports = router

View File

@ -20,6 +20,7 @@ const integrationRoutes = require("./integration")
const permissionRoutes = require("./permission")
const datasourceRoutes = require("./datasource")
const queryRoutes = require("./query")
const hostingRoutes = require("./hosting")
exports.mainRoutes = [
deployRoutes,
@ -40,6 +41,7 @@ exports.mainRoutes = [
permissionRoutes,
datasourceRoutes,
queryRoutes,
hostingRoutes,
// these need to be handled last as they still use /api/:tableId
// this could be breaking as koa may recognise other routes as this
tableRoutes,

View File

@ -9,6 +9,7 @@ const env = require("./environment")
const eventEmitter = require("./events")
const automations = require("./automations/index")
const Sentry = require("@sentry/node")
const selfhost = require("./selfhost")
const app = new Koa()
@ -49,9 +50,12 @@ destroyable(server)
server.on("close", () => console.log("Server Closed"))
module.exports = server.listen(env.PORT || 4001, () => {
module.exports = server.listen(env.PORT || 4001, async () => {
console.log(`Budibase running on ${JSON.stringify(server.address())}`)
automations.init()
if (env.SELF_HOSTED) {
await selfhost.init()
}
})
process.on("uncaughtException", err => {

View File

@ -41,3 +41,5 @@ const USERS_TABLE_SCHEMA = {
exports.AuthTypes = AuthTypes
exports.USERS_TABLE_SCHEMA = USERS_TABLE_SCHEMA
exports.BUILDER_CONFIG_DB = "builder-config-db"
exports.HOSTING_DOC = "hosting-doc"

View File

@ -1,3 +1,5 @@
const { getLogoUrl } = require("../utilities")
const BASE_LAYOUT_PROP_IDS = {
PRIVATE: "layout_private_master",
PUBLIC: "layout_public_master",
@ -107,8 +109,7 @@ const BASE_LAYOUTS = [
active: {},
selected: {},
},
logoUrl:
"https://d33wubrfki0l68.cloudfront.net/aac32159d7207b5085e74a7ef67afbb7027786c5/2b1fd/img/logo/bb-emblem.svg",
logoUrl: getLogoUrl(),
title: "",
backgroundColor: "",
color: "",

View File

@ -1,5 +1,6 @@
const { BUILTIN_ROLE_IDS } = require("../utilities/security/roles")
const { BASE_LAYOUT_PROP_IDS } = require("./layouts")
const { getLogoUrl } = require("../utilities")
exports.createHomeScreen = () => ({
description: "",
@ -138,8 +139,7 @@ exports.createLoginScreen = app => ({
active: {},
selected: {},
},
logo:
"https://d33wubrfki0l68.cloudfront.net/aac32159d7207b5085e74a7ef67afbb7027786c5/2b1fd/img/logo/bb-emblem.svg",
logo: getLogoUrl(),
title: `Log in to ${app.name}`,
buttonText: "Log In",
_children: [],

View File

@ -27,6 +27,7 @@ module.exports = {
BUDIBASE_ENVIRONMENT: process.env.BUDIBASE_ENVIRONMENT,
SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
CLOUD: process.env.CLOUD,
SELF_HOSTED: process.env.SELF_HOSTED,
DYNAMO_ENDPOINT: process.env.DYNAMO_ENDPOINT,
AWS_REGION: process.env.AWS_REGION,
DEPLOYMENT_CREDENTIALS_URL: process.env.DEPLOYMENT_CREDENTIALS_URL,
@ -35,6 +36,8 @@ module.exports = {
ENABLE_ANALYTICS: process.env.ENABLE_ANALYTICS,
DEPLOYMENT_DB_URL: process.env.DEPLOYMENT_DB_URL,
LOCAL_TEMPLATES: process.env.LOCAL_TEMPLATES,
// self hosting features
LOGO_URL: process.env.LOGO_URL,
_set(key, value) {
process.env[key] = value
module.exports[key] = value

View File

@ -7,7 +7,7 @@ const {
doesHavePermission,
} = require("../utilities/security/permissions")
const env = require("../environment")
const { apiKeyTable } = require("../db/dynamoClient")
const { isAPIKeyValid } = require("../utilities/security/apikey")
const { AuthTypes } = require("../constants")
const ADMIN_ROLES = [BUILTIN_ROLE_IDS.ADMIN, BUILTIN_ROLE_IDS.BUILDER]
@ -21,11 +21,7 @@ module.exports = (permType, permLevel = null) => async (ctx, next) => {
}
if (env.CLOUD && ctx.headers["x-api-key"] && ctx.headers["x-instanceid"]) {
// api key header passed by external webhook
const apiKeyInfo = await apiKeyTable.get({
primary: ctx.headers["x-api-key"],
})
if (apiKeyInfo) {
if (await isAPIKeyValid(ctx.headers["x-api-key"])) {
ctx.auth = {
authenticated: AuthTypes.EXTERNAL,
apiKey: ctx.headers["x-api-key"],

View File

@ -43,6 +43,10 @@ module.exports = async (ctx, next) => {
return
}
}
// if running in builder or a self hosted cloud usage quotas should not be executed
if (!env.CLOUD || env.SELF_HOSTED) {
return next()
}
// update usage for uploads to be the total size
if (property === usageQuota.Properties.UPLOAD) {
const files =
@ -51,9 +55,6 @@ module.exports = async (ctx, next) => {
: [ctx.request.files.file]
usage = files.map(file => file.size).reduce((total, size) => total + size)
}
if (!env.CLOUD) {
return next()
}
try {
await usageQuota.update(ctx.auth.apiKey, property, usage)
return next()

View File

@ -0,0 +1,7 @@
### Self hosting
This directory contains utilities that are needed for self hosted platforms to operate.
These will mostly be utilities, necessary to the operation of the server e.g. storing self
hosting specific options and attributes to CouchDB.
All the internal operations should be exposed through the `index.js` so importing
the self host directory should give you everything you need.

View File

@ -0,0 +1,44 @@
const CouchDB = require("../db")
const env = require("../environment")
const newid = require("../db/newid")
const SELF_HOST_DB = "self-host-db"
const SELF_HOST_DOC = "self-host-info"
async function createSelfHostDB(db) {
await db.put({
_id: "_design/database",
views: {},
})
const selfHostInfo = {
_id: SELF_HOST_DOC,
apiKeyId: newid(),
}
await db.put(selfHostInfo)
return selfHostInfo
}
exports.init = async () => {
if (!env.SELF_HOSTED) {
return
}
const db = new CouchDB(SELF_HOST_DB)
try {
await db.get(SELF_HOST_DOC)
} catch (err) {
// failed to retrieve
if (err.status === 404) {
await createSelfHostDB(db)
}
}
}
exports.getSelfHostInfo = async () => {
const db = new CouchDB(SELF_HOST_DB)
return db.get(SELF_HOST_DOC)
}
exports.getSelfHostAPIKey = async () => {
const info = await exports.getSelfHostInfo()
return info ? info.apiKeyId : null
}

View File

@ -0,0 +1,83 @@
const CouchDB = require("../../db")
const { BUILDER_CONFIG_DB, HOSTING_DOC } = require("../../constants")
const PROD_HOSTING_URL = "app.budi.live"
function getProtocol(hostingInfo) {
return hostingInfo.useHttps ? "https://" : "http://"
}
async function getURLWithPath(pathIfSelfHosted) {
const hostingInfo = await exports.getHostingInfo()
const protocol = getProtocol(hostingInfo)
const path =
hostingInfo.type === exports.HostingTypes.SELF ? pathIfSelfHosted : ""
return `${protocol}${hostingInfo.hostingUrl}${path}`
}
exports.HostingTypes = {
CLOUD: "cloud",
SELF: "self",
}
exports.getHostingInfo = async () => {
const db = new CouchDB(BUILDER_CONFIG_DB)
let doc
try {
doc = await db.get(HOSTING_DOC)
} catch (err) {
// don't write this doc, want to be able to update these default props
// for our servers with a new release without needing to worry about state of
// PouchDB in peoples installations
doc = {
_id: HOSTING_DOC,
type: exports.HostingTypes.CLOUD,
hostingUrl: PROD_HOSTING_URL,
selfHostKey: "",
templatesUrl: "prod-budi-templates.s3-eu-west-1.amazonaws.com",
useHttps: true,
}
}
return doc
}
exports.getAppUrl = async appId => {
const hostingInfo = await exports.getHostingInfo()
const protocol = getProtocol(hostingInfo)
let url
if (hostingInfo.type === exports.HostingTypes.CLOUD) {
url = `${protocol}${appId}.${hostingInfo.hostingUrl}`
} else {
url = `${protocol}${hostingInfo.hostingUrl}/app`
}
return url
}
exports.getWorkerUrl = async () => {
return getURLWithPath("/worker")
}
exports.getMinioUrl = async () => {
return getURLWithPath("/")
}
exports.getCouchUrl = async () => {
return getURLWithPath("/db")
}
exports.getSelfHostKey = async () => {
const hostingInfo = await exports.getHostingInfo()
return hostingInfo.selfHostKey
}
exports.getTemplatesUrl = async (appId, type, name) => {
const hostingInfo = await exports.getHostingInfo()
const protocol = getProtocol(hostingInfo)
let path
if (type && name) {
path = `templates/type/${name}.tar.gz`
} else {
path = "manifest.json"
}
return `${protocol}${hostingInfo.templatesUrl}/${path}`
}

View File

@ -168,3 +168,16 @@ exports.coerceRowValues = (row, table) => {
}
return clonedRow
}
/**
* Gets the correct link to the logo URL depending on if running in Cloud or if running in self hosting.
* @returns {string} A URL which links to the correct default logo for new apps.
*/
exports.getLogoUrl = () => {
const BB_LOGO_URL =
"https://d33wubrfki0l68.cloudfront.net/aac32159d7207b5085e74a7ef67afbb7027786c5/2b1fd/img/logo/bb-emblem.svg"
if (env.SELF_HOSTED) {
return env.LOGO_URL || BB_LOGO_URL
}
return BB_LOGO_URL
}

View File

@ -0,0 +1,23 @@
const { apiKeyTable } = require("../../db/dynamoClient")
const env = require("../../environment")
const { getSelfHostAPIKey } = require("../../selfhost")
/**
* This file purely exists so that we can centralise all logic pertaining to API keys, as their usage differs
* in our Cloud environment versus self hosted.
*/
exports.isAPIKeyValid = async apiKeyId => {
if (env.CLOUD && !env.SELF_HOSTED) {
let apiKeyInfo = await apiKeyTable.get({
primary: apiKeyId,
})
return apiKeyInfo != null
}
if (env.SELF_HOSTED) {
const selfHostKey = await getSelfHostAPIKey()
// if the api key supplied is correct then return structure similar
return apiKeyId === selfHostKey ? { pk: apiKeyId } : null
}
return false
}

File diff suppressed because it is too large Load Diff

2
packages/worker/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
.env

View File

@ -0,0 +1,15 @@
FROM node:12-alpine
WORKDIR /app
# copy files and install dependencies
COPY . ./
RUN yarn
EXPOSE 4001
# have to add node environment production after install
# due to this causing yarn to stop installing dev dependencies
# which are actually needed to get this environment up and running
ENV NODE_ENV=production
CMD ["yarn", "run:docker"]

View File

@ -0,0 +1,34 @@
{
"name": "@budibase/deployment",
"email": "hi@budibase.com",
"version": "0.3.8",
"description": "Budibase Deployment Server",
"main": "src/index.js",
"repository": {
"type": "git",
"url": "https://github.com/Budibase/budibase.git"
},
"keywords": [
"budibase"
],
"scripts": {
"run:docker": "node src/index.js"
},
"author": "Budibase",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@koa/router": "^8.0.0",
"aws-sdk": "^2.811.0",
"got": "^11.8.1",
"joi": "^17.2.1",
"koa": "^2.7.0",
"koa-body": "^4.2.0",
"koa-compress": "^4.0.1",
"koa-pino-logger": "^3.0.0",
"koa-send": "^5.0.0",
"koa-session": "^5.12.0",
"koa-static": "^5.0.0",
"pino-pretty": "^4.0.0",
"server-destroy": "^1.0.1"
}
}

View File

@ -0,0 +1,92 @@
const env = require("../../environment")
const got = require("got")
const AWS = require("aws-sdk")
const APP_BUCKET = "app-assets"
// this doesn't matter in self host
const REGION = "eu-west-1"
const PUBLIC_READ_POLICY = {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Principal: {
AWS: ["*"],
},
Action: "s3:GetObject",
Resource: [`arn:aws:s3:::${APP_BUCKET}/*`],
},
],
}
async function getCouchSession() {
// fetch session token for the api user
const session = await got.post(`${env.RAW_COUCH_DB_URL}/_session`, {
responseType: "json",
credentials: "include",
json: {
username: env.COUCH_DB_USERNAME,
password: env.COUCH_DB_PASSWORD,
},
})
const cookie = session.headers["set-cookie"][0]
// Get the session cookie value only
return cookie.split(";")[0]
}
async function getMinioSession() {
AWS.config.update({
accessKeyId: env.MINIO_ACCESS_KEY,
secretAccessKey: env.MINIO_SECRET_KEY,
})
// make sure the bucket exists
const objClient = new AWS.S3({
endpoint: env.RAW_MINIO_URL,
region: REGION,
s3ForcePathStyle: true, // needed with minio?
params: {
Bucket: APP_BUCKET,
},
})
// make sure the bucket exists
try {
await objClient
.headBucket({
Bucket: APP_BUCKET,
})
.promise()
} catch (err) {
// bucket doesn't exist create it
if (err.statusCode === 404) {
await objClient
.createBucket({
Bucket: APP_BUCKET,
})
.promise()
} else {
throw err
}
}
// always make sure policy is correct
await objClient
.putBucketPolicy({
Bucket: APP_BUCKET,
Policy: JSON.stringify(PUBLIC_READ_POLICY),
})
.promise()
// Ideally want to send back some pre-signed URLs for files that are to be uploaded
return {
accessKeyId: env.MINIO_ACCESS_KEY,
secretAccessKey: env.MINIO_SECRET_KEY,
}
}
exports.deploy = async ctx => {
ctx.body = {
couchDbSession: await getCouchSession(),
bucket: APP_BUCKET,
objectStoreSession: await getMinioSession(),
}
}

View File

@ -0,0 +1,45 @@
const Router = require("@koa/router")
const compress = require("koa-compress")
const zlib = require("zlib")
const { routes } = require("./routes")
const router = new Router()
router
.use(
compress({
threshold: 2048,
gzip: {
flush: zlib.Z_SYNC_FLUSH,
},
deflate: {
flush: zlib.Z_SYNC_FLUSH,
},
br: false,
})
)
.use("/health", ctx => (ctx.status = 200))
// error handling middleware
router.use(async (ctx, next) => {
try {
await next()
} catch (err) {
ctx.log.error(err)
ctx.status = err.status || err.statusCode || 500
ctx.body = {
message: err.message,
status: ctx.status,
}
}
})
router.get("/health", ctx => (ctx.status = 200))
// authenticated routes
for (let route of routes) {
router.use(route.routes())
router.use(route.allowedMethods())
}
module.exports = router

View File

@ -0,0 +1,9 @@
const Router = require("@koa/router")
const controller = require("../controllers/deploy")
const checkKey = require("../../middleware/check-key")
const router = Router()
router.post("/api/deploy", checkKey, controller.deploy)
module.exports = router

View File

@ -0,0 +1,3 @@
const deployRoutes = require("./deploy")
exports.routes = [deployRoutes]

View File

@ -0,0 +1,18 @@
module.exports = {
SELF_HOSTED: process.env.SELF_HOSTED,
WORKER_API_KEY: process.env.WORKER_API_KEY,
PORT: process.env.PORT,
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY,
COUCH_DB_USERNAME: process.env.COUCH_DB_USERNAME,
COUCH_DB_PASSWORD: process.env.COUCH_DB_PASSWORD,
RAW_COUCH_DB_URL: process.env.RAW_COUCH_DB_URL,
RAW_MINIO_URL: process.env.RAW_MINIO_URL,
COUCH_DB_PORT: process.env.COUCH_DB_PORT,
MINIO_PORT: process.env.MINIO_PORT,
SELF_HOST_KEY: process.env.SELF_HOST_KEY,
_set(key, value) {
process.env[key] = value
module.exports[key] = value
},
}

View File

@ -0,0 +1,48 @@
const Koa = require("koa")
const destroyable = require("server-destroy")
const koaBody = require("koa-body")
const logger = require("koa-pino-logger")
const http = require("http")
const api = require("./api")
const env = require("./environment")
const app = new Koa()
if (!env.SELF_HOSTED) {
throw "Currently this service only supports use in self hosting"
}
// set up top level koa middleware
app.use(koaBody({ multipart: true }))
app.use(
logger({
prettyPrint: {
levelFirst: true,
},
level: env.LOG_LEVEL || "error",
})
)
// api routes
app.use(api.routes())
const server = http.createServer(app.callback())
destroyable(server)
server.on("close", () => console.log("Server Closed"))
module.exports = server.listen(env.PORT || 4002, async () => {
console.log(`Worker running on ${JSON.stringify(server.address())}`)
})
process.on("uncaughtException", err => {
console.error(err)
server.close()
server.destroy()
})
process.on("SIGTERM", () => {
server.close()
server.destroy()
})

View File

@ -0,0 +1,12 @@
const env = require("../environment")
module.exports = async (ctx, next) => {
if (
!ctx.request.body.selfHostKey ||
env.SELF_HOST_KEY !== ctx.request.body.selfHostKey
) {
ctx.throw(401, "Deployment unauthorised")
} else {
await next()
}
}

1166
packages/worker/yarn.lock Normal file

File diff suppressed because it is too large Load Diff