Merge master.

This commit is contained in:
Sam Rose 2025-04-08 17:09:10 +01:00
commit 0446450a75
No known key found for this signature in database
37 changed files with 1104 additions and 55 deletions

View File

@ -17,6 +17,7 @@ COPY error.html /usr/share/nginx/html/error.html
# Default environment
ENV PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND=10
ENV PROXY_RATE_LIMIT_API_PER_SECOND=20
ENV PROXY_TIMEOUT_SECONDS=120
# Use docker-compose values as defaults for backwards compatibility
ENV APPS_UPSTREAM_URL=http://app-service:4002
ENV WORKER_UPSTREAM_URL=http://worker-service:4003

View File

@ -144,9 +144,9 @@ http {
limit_req zone=ratelimit burst=20 nodelay;
# 120s timeout on API requests
proxy_read_timeout 120s;
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_read_timeout ${PROXY_TIMEOUT_SECONDS}s;
proxy_connect_timeout ${PROXY_TIMEOUT_SECONDS}s;
proxy_send_timeout ${PROXY_TIMEOUT_SECONDS}s;
proxy_http_version 1.1;
proxy_set_header Connection $connection_upgrade;
@ -164,9 +164,9 @@ http {
# Rest of configuration copied from /api/ location above
# 120s timeout on API requests
proxy_read_timeout 120s;
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_read_timeout ${PROXY_TIMEOUT_SECONDS}s;
proxy_connect_timeout ${PROXY_TIMEOUT_SECONDS}s;
proxy_send_timeout ${PROXY_TIMEOUT_SECONDS}s;
proxy_http_version 1.1;
proxy_set_header Connection $connection_upgrade;

View File

@ -83,7 +83,7 @@ const CSP_DIRECTIVES = {
"https://js.intercomcdn.com",
"https://cdn.budi.live",
],
"worker-src": ["blob:"],
"worker-src": ["blob:", "'self'"],
}
export async function contentSecurityPolicy(ctx: any, next: any) {

View File

@ -3,6 +3,7 @@ import * as objectStore from "../objectStore"
import * as cloudfront from "../cloudfront"
import qs from "querystring"
import { DEFAULT_TENANT_ID, getTenantId } from "../../context"
import { PWAManifestImage } from "@budibase/types"
export function clientLibraryPath(appId: string) {
return `${objectStore.sanitizeKey(appId)}/budibase-client.js`
@ -51,3 +52,26 @@ export async function getAppFileUrl(s3Key: string) {
return await objectStore.getPresignedUrl(env.APPS_BUCKET_NAME, s3Key)
}
}
export async function enrichPWAImages(
images: PWAManifestImage[]
): Promise<PWAManifestImage[]> {
if (images.length === 0) {
return []
}
try {
return await Promise.all(
images.map(async image => {
return {
...image,
src: await getAppFileUrl(image.src),
type: image.type || "image/png",
}
})
)
} catch (error) {
console.error("Error enriching PWA images:", error)
return images
}
}

View File

@ -134,7 +134,7 @@
return getThemeClassNames(theme)
}
const onChange = (value: string | null) => {
const onChange = (value: string | undefined) => {
dispatch("change", value)
dropdown?.hide()
}
@ -243,7 +243,7 @@
size="S"
name="Close"
hoverable
on:click={() => onChange(null)}
on:click={() => onChange(undefined)}
/>
</div>
</div>

View File

@ -7,6 +7,7 @@
const BYTES_IN_MB = 1000000
export let value: File | undefined = undefined
export let statusText: string | undefined = undefined
export let title: string = "Upload file"
export let disabled: boolean = false
export let allowClear: boolean | undefined = undefined
@ -57,7 +58,16 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="field">
{#if value}
{#if statusText}
<div class="file-view status">
<div class="filename">{statusText}</div>
{#if !disabled || (allowClear === true && disabled)}
<div class="delete-button" on:click={clearFile}>
<Icon name="Close" size="XS" />
</div>
{/if}
</div>
{:else if value}
<div class="file-view">
{#if previewUrl}
<img class="preview" alt="" src={previewUrl} />
@ -97,6 +107,9 @@
border-radius: var(--spectrum-global-dimension-size-50);
padding: 0px var(--spectrum-alias-item-padding-m);
}
.file-view.status {
background-color: var(--spectrum-global-color-gray-100);
}
input[type="file"] {
display: none;
}

View File

@ -13,6 +13,7 @@
export let error: string | undefined = undefined
export let title: string | undefined = undefined
export let value: File | undefined = undefined
export let statusText: string | undefined = undefined
export let tooltip: string | undefined = undefined
export let helpText: string | undefined = undefined
@ -29,6 +30,7 @@
{allowClear}
{title}
{value}
{statusText}
{previewUrl}
{handleFileTooLarge}
{extensions}

View File

@ -238,3 +238,21 @@ export const hexToRGBA = (color: string, opacity: number): string => {
const b = parseInt(color.substring(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${opacity})`
}
export function rgbToHex(rgbStr: string | undefined): string {
if (rgbStr?.startsWith("#")) return rgbStr
const rgbMatch = rgbStr?.match(
/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)/
)
if (!rgbMatch) return rgbStr || "#FFFFFF"
const r = parseInt(rgbMatch[1])
const g = parseInt(rgbMatch[2])
const b = parseInt(rgbMatch[3])
return `#${((1 << 24) | (r << 16) | (g << 8) | b)
.toString(16)
.slice(1)
.toUpperCase()}`
}

View File

@ -38,7 +38,7 @@
},
},
[FIELDS.OPTIONS.type]: {
label: "Options",
label: "Single select",
value: FIELDS.OPTIONS.type,
config: {
type: FIELDS.OPTIONS.type,
@ -46,7 +46,7 @@
},
},
[FIELDS.ARRAY.type]: {
label: "Multi-select",
label: "Multi select",
value: FIELDS.ARRAY.type,
config: {
type: FIELDS.ARRAY.type,

View File

@ -29,6 +29,11 @@
url={$url("./embed")}
active={$isActive("./embed")}
/>
<SideNavItem
text="Progressive Web App"
url={$url("./pwa")}
active={$isActive("./pwa")}
/>
<SideNavItem
text="Export/Import"
url={$url("./exportImport")}

View File

@ -0,0 +1,300 @@
<script lang="ts">
import {
Layout,
Divider,
Heading,
Body,
Input,
Icon,
ColorPicker,
Button,
Label,
File,
notifications,
Select,
Tags,
Tag,
Helpers,
} from "@budibase/bbui"
import { appStore } from "@/stores/builder"
import { licensing } from "@/stores/portal"
import { API } from "@/api"
const DISPLAY_OPTIONS = [
{ label: "Standalone", value: "standalone" },
{ label: "Fullscreen", value: "fullscreen" },
{ label: "Minimal UI", value: "minimal-ui" },
]
let pwaEnabled = $licensing.pwaEnabled
let uploadingIcons = false
let pwaConfig = $appStore.pwa || {
name: "",
short_name: "",
description: "",
icons: [],
screenshots: [],
background_color: "#FFFFFF",
theme_color: "#FFFFFF",
display: "standalone",
start_url: "",
}
$: iconCount = pwaConfig.icons?.length || 0
$: iconStatusText = iconCount ? `${iconCount} icons uploaded` : undefined
function getCssVariableValue(cssVar: string) {
try {
if (cssVar?.startsWith("#")) return cssVar
const varMatch = cssVar?.match(/var\((.*?)\)/)
if (!varMatch) return "#FFFFFF"
const varName = varMatch?.[1]
const cssValue =
varName &&
getComputedStyle(document.documentElement)
.getPropertyValue(varName)
.trim()
return Helpers.rgbToHex(cssValue || "#FFFFFF")
} catch (error) {
console.error("Error converting CSS variable:", error)
return "#FFFFFF"
}
}
async function handlePWAZip(file: File) {
if (!file) {
notifications.error("No file selected")
return
}
try {
uploadingIcons = true
const data = new FormData()
data.append("file", file as any)
const result = await API.uploadPWAZip(data)
pwaConfig.icons = result.icons
notifications.success(`Processed ${pwaConfig.icons.length} icons`)
} catch (error: any) {
notifications.error("Failed to process zip: " + error.message)
} finally {
uploadingIcons = false
}
}
const handleSubmit = async () => {
try {
const pwaConfigToSave = {
...pwaConfig,
background_color: getCssVariableValue(pwaConfig.background_color),
theme_color: getCssVariableValue(pwaConfig.theme_color),
}
await API.saveAppMetadata($appStore.appId, { pwa: pwaConfigToSave })
appStore.update(state => ({ ...state, pwa: pwaConfigToSave }))
notifications.success("PWA settings saved successfully")
} catch (error) {
notifications.error("Error saving PWA settings")
}
}
</script>
<Layout noPadding>
<Layout gap="XS" noPadding>
<div class="title-section">
<Heading>Progressive Web App</Heading>
{#if !pwaEnabled}
<Tags>
<Tag icon="LockClosed">Enterprise</Tag>
</Tags>
{/if}
</div>
<Body>
Transform your app into an installable, app-like experience with a
Progressive Web App (PWA). Developers can configure app details and
visuals to create a branded, professional experience for their users.
</Body>
</Layout>
<Divider />
<div class="form" class:disabled={!pwaEnabled}>
<div class="fields">
<!-- App Details Section -->
<div class="section">
<Heading size="S">App details</Heading>
<Body size="S">
Define the identity of your app, including its name, description, and
how it will appear to users when installed.
</Body>
</div>
<div class="field">
<Label size="L">App name</Label>
<div>
<Input
bind:value={pwaConfig.name}
placeholder="Full name of your app"
disabled={!pwaEnabled}
/>
</div>
</div>
<div class="field">
<Label size="L">Short name</Label>
<div>
<Input
bind:value={pwaConfig.short_name}
placeholder="Short name for app icon"
disabled={!pwaEnabled}
/>
</div>
</div>
<div class="field">
<Label size="L">Description</Label>
<div>
<Input
bind:value={pwaConfig.description}
placeholder="Describe your app"
disabled={!pwaEnabled}
/>
</div>
</div>
<Divider />
<!-- Appearance Section -->
<div class="section">
<Heading size="S">Appearance</Heading>
<Body size="S">
Make your app visually appealing with a custom icon and theme. These
settings control how your app appears on splash screens and device
interfaces.
</Body>
</div>
<div class="field">
<div
style="display: flex; align-items: center; gap: var(--spacing-xs);"
>
<Label size="L">App icons</Label>
<Icon
size="XS"
name="Info"
tooltip="Please check our docs for details on a valid ZIP file"
/>
</div>
<div>
<File
title="Upload zip"
handleFileTooLarge={() =>
notifications.error("File too large. 20mb limit")}
extensions={[".zip"]}
on:change={e => e.detail && handlePWAZip(e.detail)}
statusText={iconStatusText}
disabled={!pwaEnabled || uploadingIcons}
/>
</div>
</div>
<div class="field">
<Label size="L">Background color</Label>
<div>
<ColorPicker
value={pwaConfig.background_color}
on:change={e => (pwaConfig.background_color = e.detail)}
/>
</div>
</div>
<div class="field">
<Label size="L">Theme color</Label>
<div>
<ColorPicker
value={pwaConfig.theme_color}
on:change={e => (pwaConfig.theme_color = e.detail)}
/>
</div>
</div>
<Divider />
<!-- Manifest Settings Section -->
<div class="section">
<Heading size="S">Manifest settings</Heading>
<Body size="S">
The manifest settings control how your app behaves once installed.
These settings define how it appears on the user's device. Configuring
these fields ensures your app is treated as a native-like application.
</Body>
</div>
<div class="field">
<Label size="L">Display mode</Label>
<div>
<Select
bind:value={pwaConfig.display}
options={DISPLAY_OPTIONS}
disabled={!pwaEnabled}
/>
</div>
</div>
<div class="actions">
<Button cta on:click={handleSubmit} disabled={!pwaEnabled}>Save</Button>
</div>
</div>
</div>
</Layout>
<style>
.form {
max-width: 100%;
position: relative;
}
.disabled {
pointer-events: none;
}
.fields {
display: grid;
grid-gap: var(--spacing-l);
opacity: var(--form-opacity, 1);
}
.disabled .fields {
--form-opacity: 0.2;
}
.field {
display: grid;
grid-template-columns: 120px 1fr;
grid-gap: var(--spacing-l);
align-items: center;
}
.field > div {
max-width: 300px;
}
.section {
margin-top: var(--spacing-xl);
}
.title-section {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: var(--spacing-m);
}
.actions {
display: flex;
margin-top: var(--spacing-xl);
}
</style>

View File

@ -7,6 +7,7 @@ import {
AppScript,
AutomationSettings,
Plugin,
PWAManifest,
UpdateAppRequest,
} from "@budibase/types"
import { get } from "svelte/store"
@ -49,6 +50,7 @@ interface AppMetaState {
revertableVersion?: string
upgradableVersion?: string
icon?: AppIcon
pwa?: PWAManifest
scripts: AppScript[]
}
@ -83,6 +85,16 @@ export const INITIAL_APP_META_STATE: AppMetaState = {
usedPlugins: [],
automations: {},
routes: {},
pwa: {
name: "",
short_name: "",
description: "",
icons: [],
background_color: "",
theme_color: "",
start_url: "",
screenshots: [],
},
scripts: [],
}
@ -115,6 +127,7 @@ export class AppMetaStore extends BudiStore<AppMetaState> {
initialised: true,
automations: app.automations || {},
hasAppPackage: true,
pwa: app.pwa,
scripts: app.scripts || [],
}))
}
@ -171,6 +184,17 @@ export class AppMetaStore extends BudiStore<AppMetaState> {
name,
url,
icon,
pwa: {
...state.pwa,
name: state.pwa?.name || "",
short_name: state.pwa?.short_name || "",
description: state.pwa?.description || "",
icons: state.pwa?.icons || [],
background_color: state.pwa?.background_color || "",
theme_color: state.pwa?.theme_color || "",
start_url: state.pwa?.start_url || "",
screenshots: state.pwa?.screenshots || [],
},
}))
}
}

View File

@ -66,6 +66,7 @@ describe("Application Meta Store", () => {
appId,
url,
features,
pwa,
componentLibraries,
} = app
@ -88,6 +89,7 @@ describe("Application Meta Store", () => {
hasLock,
initialised: true,
hasAppPackage: true,
pwa,
})
})

View File

@ -257,6 +257,7 @@ export const generateAppPackage = ({
icon: {},
type: "app",
},
pwa: {},
clientLibPath: `https://cdn.budibase.net/${appId}/budibase-client.js?v=${version}`,
hasLock: true,
}

View File

@ -31,6 +31,7 @@ interface LicensingState {
groupsEnabled: boolean
backupsEnabled: boolean
brandingEnabled: boolean
pwaEnabled: boolean
scimEnabled: boolean
environmentVariablesEnabled: boolean
budibaseAIEnabled: boolean
@ -74,6 +75,7 @@ class LicensingStore extends BudiStore<LicensingState> {
groupsEnabled: false,
backupsEnabled: false,
brandingEnabled: false,
pwaEnabled: false,
scimEnabled: false,
environmentVariablesEnabled: false,
budibaseAIEnabled: false,
@ -172,6 +174,7 @@ class LicensingStore extends BudiStore<LicensingState> {
)
const enforceableSSO = features.includes(Constants.Features.ENFORCEABLE_SSO)
const brandingEnabled = features.includes(Constants.Features.BRANDING)
const pwaEnabled = features.includes(Constants.Features.PWA)
const auditLogsEnabled = features.includes(Constants.Features.AUDIT_LOGS)
const syncAutomationsEnabled = features.includes(
Constants.Features.SYNC_AUTOMATIONS
@ -201,6 +204,7 @@ class LicensingStore extends BudiStore<LicensingState> {
groupsEnabled,
backupsEnabled,
brandingEnabled,
pwaEnabled,
budibaseAIEnabled,
customAIConfigsEnabled,
scimEnabled,

View File

@ -26,6 +26,7 @@
import NotificationDisplay from "./overlay/NotificationDisplay.svelte"
import ConfirmationDisplay from "./overlay/ConfirmationDisplay.svelte"
import PeekScreenDisplay from "./overlay/PeekScreenDisplay.svelte"
import InstallPrompt from "./overlay/InstallPrompt.svelte"
import UserBindingsProvider from "./context/UserBindingsProvider.svelte"
import DeviceBindingsProvider from "./context/DeviceBindingsProvider.svelte"
import StateBindingsProvider from "./context/StateBindingsProvider.svelte"
@ -246,6 +247,7 @@
<NotificationDisplay />
<ConfirmationDisplay />
<PeekScreenDisplay />
<InstallPrompt />
</CustomThemeWrapper>
{/if}

View File

@ -0,0 +1,90 @@
<script>
import { onMount } from "svelte"
import { appStore, featuresStore } from "@/stores"
const STORAGE_KEY_PREFIX = "pwa-install-declined"
let showButton = false
$: pwaEnabled = $featuresStore.pwaEnabled
function checkForDeferredPrompt() {
if (!pwaEnabled) {
return false
}
const appId = $appStore.appId
const storageKey = `${STORAGE_KEY_PREFIX}-${appId}`
if (localStorage.getItem(storageKey) === "true") {
return false
}
if (typeof window !== "undefined" && window.deferredPwaPrompt) {
showButton = true
return true
}
return false
}
async function installPWA() {
if (!window.deferredPwaPrompt) return
window.deferredPwaPrompt.prompt()
const { outcome } = await window.deferredPwaPrompt.userChoice
if (outcome === "accepted") {
showButton = false
} else if (outcome === "dismissed") {
const appId = $appStore.appId
const storageKey = `${STORAGE_KEY_PREFIX}-${appId}`
localStorage.setItem(storageKey, "true")
showButton = false
}
window.deferredPwaPrompt = null
}
onMount(async () => {
if ("serviceWorker" in navigator) {
try {
await navigator.serviceWorker.register("/app/service-worker.js", {
scope: "/app/",
})
} catch (error) {
console.error("Service worker registration failed:", error)
}
}
checkForDeferredPrompt()
})
</script>
{#if showButton}
<div class="install-prompt">
<button class="openMenu" on:click={installPWA}>Install app</button>
</div>
{/if}
<style>
.install-prompt {
position: fixed;
bottom: 5px;
right: 10px;
z-index: 1000;
}
.openMenu {
cursor: pointer;
background-color: var(--bb-indigo);
border-radius: 100px;
color: white;
border: none;
font-size: 13px;
font-weight: 600;
padding: 10px 18px;
transition: background-color 130ms ease-out;
}
.openMenu:hover {
background-color: var(--bb-indigo-light);
}
</style>

View File

@ -34,6 +34,14 @@ import { APIClient } from "@budibase/frontend-core"
import BlockComponent from "./components/BlockComponent.svelte"
import Block from "./components/Block.svelte"
// Set up global PWA install prompt handler
if (typeof window !== "undefined") {
window.addEventListener("beforeinstallprompt", e => {
e.preventDefault()
window.deferredPwaPrompt = e
})
}
// Provide svelte and svelte/internal as globals for custom components
import * as svelte from "svelte"
// @ts-ignore
@ -69,6 +77,9 @@ declare global {
// Other flags
MIGRATING_APP: boolean
// PWA install prompt
deferredPwaPrompt: any
// Client additions
handleBuilderRuntimeEvent: (type: string, data: any) => void
registerCustomComponent: typeof componentStore.actions.registerCustomComponent

View File

@ -41,6 +41,7 @@ const createFeaturesStore = () => {
aiEnabled:
license?.features?.includes(Feature.AI_CUSTOM_CONFIGS) ||
license?.features?.includes(Feature.BUDIBASE_AI),
pwaEnabled: license?.features?.includes(Feature.PWA),
}
})
}

View File

@ -22,6 +22,9 @@ export interface AttachmentEndpoints {
data: any
) => Promise<ProcessAttachmentResponse>
uploadBuilderAttachment: (data: any) => Promise<ProcessAttachmentResponse>
uploadPWAZip: (
data: FormData
) => Promise<{ icons: Array<{ src: string; sizes: string; type: string }> }>
externalUpload: (
datasourceId: string,
bucket: string,
@ -79,6 +82,14 @@ export const buildAttachmentEndpoints = (
})
},
uploadPWAZip: async data => {
return await API.post({
url: "/api/pwa/process-zip",
body: data,
json: false,
})
},
/**
* Uploads a file to an external datasource.
* @param datasourceId the ID of the datasource to upload to

View File

@ -88,6 +88,7 @@
"dayjs": "^1.10.8",
"dd-trace": "5.43.0",
"dotenv": "8.2.0",
"extract-zip": "^2.0.1",
"form-data": "4.0.0",
"global-agent": "3.0.0",
"google-auth-library": "^8.0.1",

View File

@ -265,6 +265,13 @@ export async function fetchAppPackage(
application.usedPlugins
)
// Enrich PWA icon URLs if they exist
if (application.pwa?.icons && application.pwa.icons.length > 0) {
application.pwa.icons = await objectStore.enrichPWAImages(
application.pwa.icons
)
}
// Only filter screens if the user is not a builder
if (!users.isBuilder(ctx.user, appId)) {
const userRoleId = getUserRoleId(ctx)
@ -867,6 +874,18 @@ export async function updateAppPackage(
newAppPackage._rev = application._rev
}
// Make sure that when saving down pwa settings, we don't override the keys with the enriched url
if (appPackage.pwa && application.pwa) {
if (appPackage.pwa.icons) {
appPackage.pwa.icons = appPackage.pwa.icons.map((icon, i) =>
icon.src.startsWith(objectStore.SIGNED_FILE_PREFIX) &&
application?.pwa?.icons?.[i]
? { ...icon, src: application?.pwa?.icons?.[i].src }
: icon
)
}
}
// the locked by property is attached by server but generated from
// Redis, shouldn't ever store it
delete newAppPackage.lockedBy

View File

@ -1,5 +1,4 @@
import { getQueryParams, getTableParams } from "../../db/utils"
import { getIntegration } from "../../integrations"
import { invalidateCachedVariable } from "../../threads/utils"
import { context, db as dbCore, events } from "@budibase/backend-core"
import {
@ -174,14 +173,6 @@ export async function update(
await events.datasource.updated(datasource)
datasource._rev = response.rev
// Drain connection pools when configuration is changed
if (datasource.source && !isBudibaseSource) {
const source = await getIntegration(datasource.source)
if (source && source.pool) {
await source.pool.end()
}
}
ctx.message = "Datasource saved successfully."
ctx.body = {
datasource: await sdk.datasources.removeSecretSingle(datasource),

View File

@ -21,6 +21,7 @@ import {
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import { PutObjectCommand, S3 } from "@aws-sdk/client-s3"
import fs from "fs"
import fsp from "fs/promises"
import sdk from "../../../sdk"
import * as pro from "@budibase/pro"
import {
@ -32,6 +33,7 @@ import {
GetSignedUploadUrlRequest,
GetSignedUploadUrlResponse,
ProcessAttachmentResponse,
PWAManifest,
ServeAppResponse,
ServeBuilderPreviewResponse,
ServeClientLibraryResponse,
@ -45,6 +47,9 @@ import {
import send from "koa-send"
import { getThemeVariables } from "../../../constants/themes"
import path from "path"
import extract from "extract-zip"
import { tmpdir } from "os"
export const toggleBetaUiFeature = async function (
ctx: Ctx<void, ToggleBetaFeatureResponse>
@ -133,6 +138,83 @@ export const uploadFile = async function (
)
}
export async function processPWAZip(ctx: UserCtx) {
const file = ctx.request.files?.file
if (!file || Array.isArray(file)) {
ctx.throw(400, "No file or multiple files provided")
}
if (!file.path || !file.name?.toLowerCase().endsWith(".zip")) {
ctx.throw(400, "Invalid file - must be a zip file")
}
const tempDir = join(tmpdir(), `pwa-${Date.now()}`)
try {
await fsp.mkdir(tempDir, { recursive: true })
await extract(file.path, { dir: tempDir })
const iconsJsonPath = join(tempDir, "icons.json")
if (!fs.existsSync(iconsJsonPath)) {
ctx.throw(400, "Invalid zip structure - missing icons.json")
}
let iconsData
try {
const iconsContent = await fsp.readFile(iconsJsonPath, "utf-8")
iconsData = JSON.parse(iconsContent)
} catch (error) {
ctx.throw(400, "Invalid icons.json file - could not parse JSON")
}
if (!iconsData.icons || !Array.isArray(iconsData.icons)) {
ctx.throw(400, "Invalid icons.json file - missing icons array")
}
const icons = []
const baseDir = path.dirname(iconsJsonPath)
const appId = context.getProdAppId()
for (const icon of iconsData.icons) {
if (!icon.src || !icon.sizes || !fs.existsSync(join(baseDir, icon.src))) {
continue
}
const extension = path.extname(icon.src) || ".png"
const key = `${appId}/pwa/${uuid.v4()}${extension}`
const mimeType =
icon.type || (extension === ".png" ? "image/png" : "image/jpeg")
try {
const result = await objectStore.upload({
bucket: ObjectStoreBuckets.APPS,
filename: key,
path: join(baseDir, icon.src),
type: mimeType,
})
if (result.Key) {
icons.push({
src: result.Key,
sizes: icon.sizes,
type: mimeType,
})
}
} catch (uploadError) {
throw new Error(`Failed to upload icon ${icon.src}: ${uploadError}`)
}
}
if (icons.length === 0) {
ctx.throw(400, "No valid icons found in the zip file")
}
ctx.body = { icons }
} catch (error: any) {
ctx.throw(500, `Error processing zip: ${error.message}`)
}
}
const requiresMigration = async (ctx: Ctx) => {
const appId = context.getAppId()
if (!appId) {
@ -199,7 +281,9 @@ export const serveApp = async function (ctx: UserCtx<void, ServeAppResponse>) {
const sideNav = appInfo.navigation.navigation === "Left"
const hideFooter =
ctx?.user?.license?.features?.includes(Feature.BRANDING) || false
const themeVariables = getThemeVariables(appInfo?.theme)
const themeVariables = getThemeVariables(appInfo?.theme || {})
const hasPWA = Object.keys(appInfo.pwa || {}).length > 0
const manifestUrl = hasPWA ? `/api/apps/${appId}/manifest.json` : ""
const addAppScripts =
ctx?.user?.license?.features?.includes(Feature.CUSTOM_APP_SCRIPTS) ||
false
@ -243,8 +327,45 @@ export const serveApp = async function (ctx: UserCtx<void, ServeAppResponse>) {
const { head, html, css } = AppComponent.render({ props })
const appHbs = loadHandlebarsFile(appHbsPath)
let extraHead = ""
const pwaEnabled = await pro.features.isPWAEnabled()
if (hasPWA && pwaEnabled) {
extraHead = `<link rel="manifest" href="${manifestUrl}">`
extraHead += `<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content=${
appInfo.pwa.theme_color
}>
<meta name="apple-mobile-web-app-title" content="${
appInfo.pwa.short_name || appInfo.name
}">`
if (appInfo.pwa.icons && appInfo.pwa.icons.length > 0) {
try {
// Enrich all icons
const enrichedIcons = await objectStore.enrichPWAImages(
appInfo.pwa.icons
)
let appleTouchIcon = enrichedIcons.find(
icon => icon.sizes === "180x180"
)
if (!appleTouchIcon && enrichedIcons.length > 0) {
appleTouchIcon = enrichedIcons[0]
}
if (appleTouchIcon) {
extraHead += `<link rel="apple-touch-icon" sizes="${appleTouchIcon.sizes}" href="${appleTouchIcon.src}">`
}
} catch (error) {
throw new Error("Error enriching PWA icons: " + error)
}
}
}
ctx.body = await processString(appHbs, {
head,
head: `${head}${extraHead}`,
body: html,
css: `:root{${themeVariables}} ${css.code}`,
appId,
@ -346,6 +467,16 @@ export const serveClientLibrary = async function (
}
}
export const serveServiceWorker = async function (ctx: Ctx) {
const serviceWorkerContent = `
self.addEventListener('install', () => {
self.skipWaiting();
});`
ctx.set("Content-Type", "application/javascript")
ctx.body = serviceWorkerContent
}
export const getSignedUploadURL = async function (
ctx: Ctx<GetSignedUploadUrlRequest, GetSignedUploadUrlResponse>
) {
@ -397,3 +528,70 @@ export const getSignedUploadURL = async function (
ctx.body = { signedUrl, publicUrl }
}
export async function servePwaManifest(ctx: UserCtx<void, any>) {
const appId = context.getAppId()
if (!appId) {
ctx.throw(404)
}
try {
const db = context.getAppDB({ skip_setup: true })
const appInfo = await db.get<App>(DocumentType.APP_METADATA)
if (!appInfo.pwa) {
ctx.throw(404)
}
const manifest: PWAManifest = {
name: appInfo.pwa.name || appInfo.name,
short_name: appInfo.pwa.short_name || appInfo.name,
description: appInfo.pwa.description || "",
start_url: `/app${appInfo.url}`,
display: appInfo.pwa.display || "standalone",
background_color: appInfo.pwa.background_color || "#FFFFFF",
theme_color: appInfo.pwa.theme_color || "#FFFFFF",
icons: [],
screenshots: [],
}
if (appInfo.pwa.icons && appInfo.pwa.icons.length > 0) {
try {
manifest.icons = await objectStore.enrichPWAImages(appInfo.pwa.icons)
const desktopScreenshot = manifest.icons.find(
icon => icon.sizes === "1240x600" || icon.sizes === "2480x1200"
)
if (desktopScreenshot) {
manifest.screenshots.push({
src: desktopScreenshot.src,
sizes: desktopScreenshot.sizes,
type: "image/png",
form_factor: "wide",
label: "Desktop view",
})
}
const mobileScreenshot = manifest.icons.find(
icon => icon.sizes === "620x620" || icon.sizes === "1024x1024"
)
if (mobileScreenshot) {
manifest.screenshots.push({
src: mobileScreenshot.src,
sizes: mobileScreenshot.sizes,
type: "image/png",
label: "Mobile view",
})
}
} catch (error) {
throw new Error("Error processing manifest icons: " + error)
}
}
ctx.set("Content-Type", "application/json")
ctx.body = manifest
} catch (error) {
ctx.status = 500
ctx.body = { message: "Error generating manifest" }
}
}

View File

@ -13,7 +13,9 @@ addFileManagement(router)
router
.get("/api/assets/client", controller.serveClientLibrary)
.get("/api/apps/:appId/manifest.json", controller.servePwaManifest)
.post("/api/attachments/process", authorized(BUILDER), controller.uploadFile)
.post("/api/pwa/process-zip", authorized(BUILDER), controller.processPWAZip)
.post("/api/beta/:feature", controller.toggleBetaUiFeature)
.post(
"/api/attachments/:tableId/upload",
@ -22,6 +24,7 @@ router
controller.uploadFile
)
.get("/app/preview", authorized(BUILDER), controller.serveBuilderPreview)
.get("/app/service-worker.js", controller.serveServiceWorker)
.get("/app/:appUrl/:path*", controller.serveApp)
.get("/:appId/:path*", controller.serveApp)
.post(

View File

@ -0,0 +1,98 @@
import { context, objectStore } from "@budibase/backend-core"
import { App, DocumentType } from "@budibase/types"
import { getRequest, getConfig, afterAll as _afterAll } from "./utilities"
describe("PWA Manifest", () => {
let request = getRequest()
let config = getConfig()
afterAll(() => {
_afterAll()
})
beforeAll(async () => {
await config.init()
})
it("should serve a valid manifest.json with properly configured PWA", async () => {
await context.doInAppContext(config.getAppId(), async () => {
const appDb = context.getAppDB()
let appDoc = await appDb.get<App>(DocumentType.APP_METADATA)
const pwaConfig = {
name: "Test App",
short_name: "TestApp",
description: "Test app description",
background_color: "#FFFFFF",
theme_color: "#4285F4",
display: "standalone",
icons: [
{
src: `${config.appId}/pwa/icon-small.png`,
sizes: "144x144",
type: "image/png",
},
{
src: `${config.appId}/pwa/icon-large.png`,
sizes: "512x512",
type: "image/png",
},
{
src: `${config.appId}/pwa/icon-screenshot.png`,
sizes: "1240x600",
type: "image/png",
},
],
}
// Upload fake icons to minio
await objectStore.upload({
bucket: "apps",
filename: `${config.appId}/pwa/icon-small.png`,
body: Buffer.from("fake-image-data"),
type: "image/png",
})
await objectStore.upload({
bucket: "apps",
filename: `${config.appId}/pwa/icon-large.png`,
body: Buffer.from("fake-image-data"),
type: "image/png",
})
await appDb.put({
...appDoc,
pwa: pwaConfig,
})
const res = await request
.get(`/api/apps/${config.appId}/manifest.json`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body).toMatchObject({
name: "Test App",
short_name: "TestApp",
description: "Test app description",
background_color: "#FFFFFF",
theme_color: "#4285F4",
display: "standalone",
start_url: expect.stringContaining(appDoc.url!),
icons: expect.arrayContaining([
expect.objectContaining({
sizes: "144x144",
type: "image/png",
src: expect.stringContaining("icon-small.png"),
}),
expect.objectContaining({
sizes: "512x512",
type: "image/png",
src: expect.stringContaining("icon-large.png"),
}),
]),
})
expect(res.body.screenshots.length).toBeGreaterThan(0)
})
})
})

View File

@ -761,7 +761,7 @@ if (descriptions.length) {
})
it("should throw an error if the incorrect actionType is specified", async () => {
const verbs = ["read", "create", "update", "delete"]
const verbs = ["read", "create", "update", "delete"] as const
for (const verb of verbs) {
const query = await createQuery({
// @ts-expect-error

View File

@ -3,7 +3,7 @@ import {
EnrichedQueryJson,
QueryJson,
} from "@budibase/types"
import { getIntegration } from "../index"
import { getIntegration, isDatasourcePlusConstructor } from "../index"
import sdk from "../../sdk"
import { enrichQueryJson } from "../../sdk/app/rows/utils"
@ -29,8 +29,7 @@ export async function makeExternalQuery(
const Integration = await getIntegration(json.datasource.source)
// query is the opinionated function
if (!Integration.prototype.query) {
if (!isDatasourcePlusConstructor(Integration)) {
throw "Datasource does not support query."
}

View File

@ -626,7 +626,7 @@ export class GoogleSheetsIntegration implements DatasourcePlus {
sheet: string
rowIndex: number
row: any
table: Table
table?: Table
}) {
try {
await this.connect()
@ -644,13 +644,15 @@ export class GoogleSheetsIntegration implements DatasourcePlus {
row.set(key, "")
}
const { type, subtype, constraints } = query.table.schema[key]
const isDeprecatedSingleUser =
type === FieldType.BB_REFERENCE &&
subtype === BBReferenceFieldSubType.USER &&
constraints?.type !== "array"
if (isDeprecatedSingleUser && Array.isArray(row.get(key))) {
row.set(key, row.get(key)[0])
if (query.table) {
const { type, subtype, constraints } = query.table.schema[key]
const isDeprecatedSingleUser =
type === FieldType.BB_REFERENCE &&
subtype === BBReferenceFieldSubType.USER &&
constraints?.type !== "array"
if (isDeprecatedSingleUser && Array.isArray(row.get(key))) {
row.set(key, row.get(key)[0])
}
}
}
await row.save()

View File

@ -19,6 +19,7 @@ import {
Integration,
PluginType,
IntegrationBase,
DatasourcePlus,
} from "@budibase/types"
import { getDatasourcePlugin } from "../utilities/fileSystem"
import env from "../environment"
@ -48,6 +49,13 @@ const DEFINITIONS: Record<SourceName, Integration | undefined> = {
}
type IntegrationBaseConstructor = new (...args: any[]) => IntegrationBase
type DatasourcePlusConstructor = new (...args: any[]) => DatasourcePlus
export function isDatasourcePlusConstructor(
integration: IntegrationBaseConstructor
): integration is DatasourcePlusConstructor {
return !!integration.prototype.query
}
const INTEGRATIONS: Record<SourceName, IntegrationBaseConstructor | undefined> =
{
@ -106,7 +114,9 @@ export async function getDefinitions() {
}
}
export async function getIntegration(integration: SourceName) {
export async function getIntegration(
integration: SourceName
): Promise<IntegrationBaseConstructor> {
if (INTEGRATIONS[integration]) {
return INTEGRATIONS[integration]
}

View File

@ -787,4 +787,143 @@ describe("Google Sheets Integration", () => {
expect(ids.length).toEqual(50)
})
})
describe("queries", () => {
let table: Table
let rows: Row[]
beforeEach(async () => {
table = await config.api.table.save({
name: "Test Table",
type: "table",
sourceId: datasource._id!,
sourceType: TableSourceType.EXTERNAL,
schema: {
name: {
name: "name",
type: FieldType.STRING,
constraints: {
type: "string",
},
},
description: {
name: "description",
type: FieldType.STRING,
constraints: {
type: "string",
},
},
},
})
rows = []
rows.push(
await config.api.row.save(table._id!, {
name: "Test Contact 1",
description: "original description 1",
})
)
rows.push(
await config.api.row.save(table._id!, {
name: "Test Contact 2",
description: "original description 2",
})
)
})
describe("read", () => {
it("should be able to read data from a sheet", async () => {
const { rows } = await config.api.query.preview({
fields: {
sheet: table.name,
},
datasourceId: datasource._id!,
parameters: [],
transformer: null,
queryVerb: "read",
name: datasource.name!,
schema: {},
readable: true,
})
expect(rows).toHaveLength(2)
expect(rows[0].name).toEqual("Test Contact 2")
expect(rows[0].description).toEqual("original description 2")
expect(rows[1].name).toEqual("Test Contact 1")
expect(rows[1].description).toEqual("original description 1")
})
})
describe("update", () => {
it("should be able to update data in a sheet", async () => {
await config.api.query.preview({
fields: {
sheet: table.name,
rowIndex: "2",
row: { name: "updated name", description: "updated description" },
},
datasourceId: datasource._id!,
parameters: [],
transformer: null,
queryVerb: "update",
name: datasource.name!,
schema: {},
readable: true,
})
const rows = await config.api.row.fetch(table._id!)
expect(rows).toHaveLength(2)
expect(rows[0].name).toEqual("updated name")
expect(rows[0].description).toEqual("updated description")
expect(rows[1].name).toEqual("Test Contact 1")
expect(rows[1].description).toEqual("original description 1")
})
})
describe("create", () => {
it("should be able to create new rows", async () => {
await config.api.query.preview({
fields: {
sheet: table.name,
row: { name: "new name", description: "new description" },
},
datasourceId: datasource._id!,
parameters: [],
transformer: null,
queryVerb: "create",
name: datasource.name!,
schema: {},
readable: true,
})
const rows = await config.api.row.fetch(table._id!)
expect(rows).toHaveLength(3)
expect(rows[0].name).toEqual("new name")
expect(rows[0].description).toEqual("new description")
})
})
describe("delete", () => {
it("should be able to delete rows", async () => {
await config.api.query.preview({
fields: {
sheet: table.name,
rowIndex: "2",
},
datasourceId: datasource._id!,
parameters: [],
transformer: null,
queryVerb: "delete",
name: datasource.name!,
schema: {},
readable: true,
})
const rows = await config.api.row.fetch(table._id!)
expect(rows).toHaveLength(1)
expect(rows[0].name).toEqual("Test Contact 1")
expect(rows[0].description).toEqual("original description 1")
})
})
})
})

View File

@ -540,9 +540,28 @@ export class GoogleSheetsMock {
throw new Error("Only row-based deletes are supported")
}
this.iterateRange(range, cell => {
cell.userEnteredValue = this.createValue(null)
})
const sheet = this.getSheetById(range.sheetId)
if (!sheet) {
throw new Error(`Sheet ${range.sheetId} not found`)
}
if (range.startRowIndex === undefined || range.endRowIndex === undefined) {
throw new Error("Range must have start and end row indexes")
}
const totalRows = sheet.data[0].rowData.length
if (totalRows < range.endRowIndex) {
throw new Error(
`Cannot delete range ${JSON.stringify(range)} from sheet ${
sheet.properties.title
}. Only ${totalRows} rows exist.`
)
}
const rowsToDelete = range.endRowIndex - range.startRowIndex
sheet.data[0].rowData.splice(range.startRowIndex, rowsToDelete)
sheet.data[0].rowMetadata.splice(range.startRowIndex, rowsToDelete)
sheet.properties.gridProperties.rowCount -= rowsToDelete
}
private handleDeleteSheet(request: DeleteSheetRequest) {

View File

@ -296,6 +296,9 @@ export async function save(
datasource: Datasource,
opts?: { fetchSchema?: boolean; tablesFilter?: string[] }
): Promise<{ datasource: Datasource; errors: Record<string, string> }> {
// getIntegration throws an error if the integration is not found
await getIntegration(datasource.source)
const db = context.getAppDB()
const plus = datasource.plus
@ -329,14 +332,6 @@ export async function save(
await events.datasource.created(datasource)
datasource._rev = dbResp.rev
// Drain connection pools when configuration is changed
if (datasource.source) {
const source = await getIntegration(datasource.source)
if (source && source.pool) {
await source.pool.end()
}
}
return { datasource, errors }
}

View File

@ -14,14 +14,21 @@ import { context, cache, auth } from "@budibase/backend-core"
import { getGlobalIDFromUserMetadataID } from "../db/utils"
import sdk from "../sdk"
import { cloneDeep } from "lodash/fp"
import { Datasource, Query, SourceName, Row } from "@budibase/types"
import {
Datasource,
Query,
SourceName,
Row,
QueryVerb,
DatasourcePlus,
} from "@budibase/types"
import { isSQL } from "../integrations/utils"
import { interpolateSQL } from "../integrations/queries/sql"
class QueryRunner {
datasource: Datasource
queryVerb: string
queryVerb: QueryVerb
queryId: string
fields: any
parameters: any
@ -116,7 +123,9 @@ class QueryRunner {
datasource.source,
fieldsClone,
enrichedContext,
integration,
// Bit hacky because currently all of our SQL datasources are
// DatasourcePluses.
integration as DatasourcePlus,
{
nullDefaultSupport,
}
@ -130,7 +139,14 @@ class QueryRunner {
query.paginationValues = this.pagination
}
let output = threadUtils.formatResponse(await integration[queryVerb](query))
const fn = integration[queryVerb]
if (!fn) {
throw new Error(
`Datasource integration does not support verb: ${queryVerb}`
)
}
let output = threadUtils.formatResponse(await fn.bind(integration)(query))
let rows = output as Row[],
info = undefined,
extra = undefined,
@ -199,7 +215,7 @@ class QueryRunner {
})
const keys: string[] = [...keysSet]
if (integration.end) {
if ("end" in integration && typeof integration.end === "function") {
integration.end()
}

View File

@ -29,6 +29,7 @@ export interface App extends Document {
snippets?: Snippet[]
creationVersion?: string
updatedBy?: string
pwa?: PWAManifest
scripts?: AppScript[]
}
@ -84,6 +85,26 @@ export interface AutomationSettings {
chainAutomations?: boolean
}
export interface PWAManifest {
name: string
short_name: string
description: string
icons: PWAManifestImage[]
screenshots: PWAManifestImage[]
background_color: string
theme_color: string
display?: string
start_url: string
}
export interface PWAManifestImage {
src: string
sizes: string
type: string
form_factor?: "wide" | "narrow" | undefined
label?: string
}
export interface AppScript {
id: string
name: string

View File

@ -8,16 +8,21 @@ export interface QuerySchema {
subtype?: string
}
export type QueryVerb = "read" | "create" | "update" | "delete"
export interface Query extends Document {
datasourceId: string
name: string
parameters: QueryParameter[]
fields: RestQueryFields & SQLQueryFields & MongoQueryFields
fields: RestQueryFields &
SQLQueryFields &
MongoQueryFields &
GoogleSheetsQueryFields
transformer: string | null
schema: Record<string, QuerySchema | string>
nestedSchemaFields?: Record<string, Record<string, QuerySchema | string>>
readable: boolean
queryVerb: string
queryVerb: QueryVerb
// flag to state whether the default bindings are empty strings (old behaviour) or null
nullDefaultSupport?: boolean
}
@ -83,6 +88,12 @@ export interface MongoQueryFields {
json?: object | string
}
export interface GoogleSheetsQueryFields {
sheet?: string
rowIndex?: string
row?: Row
}
export interface PaginationConfig {
type: string
location: string

View File

@ -7237,6 +7237,13 @@
dependencies:
"@types/yargs-parser" "*"
"@types/yauzl@^2.9.1":
version "2.10.3"
resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.3.tgz#e9b2808b4f109504a03cda958259876f61017999"
integrity sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==
dependencies:
"@types/node" "*"
"@typescript-eslint/eslint-plugin@8.17.0":
version "8.17.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.17.0.tgz#2ee073c421f4e81e02d10e731241664b6253b23c"
@ -11658,6 +11665,17 @@ external-editor@^3.0.3:
iconv-lite "^0.4.24"
tmp "^0.0.33"
extract-zip@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a"
integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==
dependencies:
debug "^4.1.1"
get-stream "^5.1.0"
yauzl "^2.10.0"
optionalDependencies:
"@types/yauzl" "^2.9.1"
extsprintf@1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
@ -22575,7 +22593,7 @@ yargs@^17.3.1, yargs@^17.6.2, yargs@^17.7.2:
y18n "^5.0.5"
yargs-parser "^21.1.1"
yauzl@^2.4.2:
yauzl@^2.10.0, yauzl@^2.4.2:
version "2.10.0"
resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==