@budibase/server
Version:
Budibase Web Server
556 lines (492 loc) • 17.4 kB
text/typescript
import { PutObjectCommand, S3 } from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import {
BadRequestError,
configs,
context,
objectStore,
utils,
} from "@budibase/backend-core"
import * as pro from "@budibase/pro"
import { InvalidFileExtensions } from "@budibase/shared-core"
import { processString } from "@budibase/string-templates"
import {
BudibaseAppProps,
Ctx,
DocumentType,
Feature,
GetSignedUploadUrlRequest,
GetSignedUploadUrlResponse,
ProcessAttachmentResponse,
PWAManifest,
ServeAppResponse,
ServeBuilderPreviewResponse,
ServeClientLibraryResponse,
UserCtx,
Workspace,
} from "@budibase/types"
import extract from "extract-zip"
import fs from "fs"
import fsp from "fs/promises"
import send from "koa-send"
import { tmpdir } from "os"
import path from "path"
import * as uuid from "uuid"
import { ObjectStoreBuckets } from "../../../constants"
import { getThemeVariables } from "../../../constants/themes"
import env from "../../../environment"
import sdk from "../../../sdk"
import { join } from "../../../utilities/centralPath"
import {
loadHandlebarsFile,
NODE_MODULES_PATH,
shouldServeLocally,
} from "../../../utilities/fileSystem"
import { isWorkspaceFullyMigrated } from "../../../workspaceMigrations"
import AppComponent from "./templates/BudibaseApp.svelte"
export const uploadFile = async function (
ctx: Ctx<void, ProcessAttachmentResponse>
) {
const file = ctx.request?.files?.file
if (!file) {
throw new BadRequestError("No file provided")
}
let files = file && Array.isArray(file) ? Array.from(file) : [file]
ctx.body = await Promise.all(
files.map(async file => {
if (!file.name) {
throw new BadRequestError(
"Attempted to upload a file without a filename"
)
}
const extension = [...file.name.split(".")].pop()
if (!extension) {
throw new BadRequestError(
`File "${file.name}" has no extension, an extension is required to upload a file`
)
}
if (
!env.SELF_HOSTED &&
InvalidFileExtensions.includes(extension.toLowerCase())
) {
throw new BadRequestError(
`File "${file.name}" has an invalid extension: "${extension}"`
)
}
// filenames converted to UUIDs so they are unique
const processedFileName = `${uuid.v4()}.${extension}`
const s3Key = `${context.getProdWorkspaceId()}/attachments/${processedFileName}`
const response = await objectStore.upload({
bucket: ObjectStoreBuckets.APPS,
filename: s3Key,
path: file.path,
type: file.type,
})
return {
size: file.size,
name: file.name,
url: await objectStore.getAppFileUrl(s3Key),
extension,
key: response.Key!,
}
})
)
}
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.getProdWorkspaceId()
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 getAppScriptHTML = (
workspace: Workspace,
location: "Head" | "Body",
nonce: string
) => {
if (!workspace.scripts?.length) {
return ""
}
return workspace.scripts
.filter(script => script.location === location && script.html?.length)
.map(script => script.html)
.join("\n")
.replaceAll("<script", `<script nonce="${nonce}"`)
}
export const serveApp = async function (ctx: UserCtx<void, ServeAppResponse>) {
// No app ID found, cannot serve - return message instead
const workspaceId = context.getWorkspaceId()
if (!workspaceId) {
ctx.body = "No content found - requires app ID"
return
}
const bbHeaderEmbed =
ctx.request.get("x-budibase-embed")?.toLowerCase() === "true"
const [fullyMigrated, settingsConfig, recaptchaConfig] = await Promise.all([
isWorkspaceFullyMigrated(workspaceId),
configs.getSettingsConfigDoc(),
configs.getRecaptchaConfig(),
])
const branding = await pro.branding.getBrandingConfig(settingsConfig.config)
// incase running direct from TS
let appHbsPath = join(__dirname, "app.hbs")
if (!fs.existsSync(appHbsPath)) {
appHbsPath = join(__dirname, "templates", "app.hbs")
}
try {
context.getWorkspaceDB({ skip_setup: true })
const workspaceApp = await sdk.workspaceApps.getMatchedWorkspaceApp(ctx.url)
const appInfo = await sdk.workspaces.metadata.get()
const hideDevTools = !!ctx.params.appUrl
const sideNav = workspaceApp?.navigation.navigation === "Left"
const hideFooter =
ctx?.user?.license?.features?.includes(Feature.BRANDING) || false
const themeVariables = getThemeVariables(appInfo.theme)
const hasPWA = Object.keys(appInfo.pwa || {}).length > 0
const manifestUrl = hasPWA ? `/api/apps/${workspaceId}/manifest.json` : ""
const addAppScripts =
ctx?.user?.license?.features?.includes(Feature.CUSTOM_APP_SCRIPTS) ||
false
if (!env.isJest()) {
const plugins = await objectStore.enrichPluginURLs(appInfo.usedPlugins)
/*
* Server rendering in svelte sadly does not support type checking, the .render function
* always will just expect "any" when typing - so it is pointless for us to type the
* BudibaseApp.svelte file as we can never detect if the types are correct. To get around this
* I've created a type which expects what the app will expect to receive.
*/
const appName = workspaceApp?.name || `${appInfo.name}`
const nonce = ctx.state.nonce || ""
let props: BudibaseAppProps = {
title: branding?.platformTitle || appName,
showSkeletonLoader: appInfo.features?.skeletonLoader ?? false,
hideDevTools,
sideNav,
hideFooter,
metaImage:
branding?.metaImageUrl ||
"https://res.cloudinary.com/daog6scxm/image/upload/v1698759482/meta-images/plain-branded-meta-image-coral_ocxmgu.png",
metaDescription: branding?.metaDescription || "",
metaTitle: branding?.metaTitle || `${appName} - built with Budibase`,
clientCacheKey: await objectStore.getClientCacheKey(appInfo.version),
usedPlugins: plugins,
favicon:
branding.faviconUrl !== ""
? await objectStore.getGlobalFileUrl("settings", "faviconUrl")
: "",
appMigrating: !fullyMigrated,
recaptchaKey: recaptchaConfig?.config.siteKey,
nonce,
workspaceId,
}
// Add custom app scripts if enabled
if (addAppScripts) {
props.headAppScripts = getAppScriptHTML(appInfo, "Head", nonce)
props.bodyAppScripts = getAppScriptHTML(appInfo, "Body", nonce)
}
const { head, html, css } = AppComponent.render({ props })
const appHbs = loadHandlebarsFile(appHbsPath)
let extraHead = ""
const pwaEnabled = await pro.features.isPWAEnabled()
if (hasPWA && appInfo.pwa && 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}${extraHead}`,
body: html,
css: `:root{${themeVariables}} ${css.code}`,
appId: workspaceId,
embedded: bbHeaderEmbed,
nonce: ctx.state.nonce,
})
} else {
// just return the app info for jest to assert on
ctx.body = appInfo
}
} catch (error: any) {
let msg = "An unknown error occurred"
if (typeof error === "string") {
msg = error
} else if (error?.message) {
msg = error.message
}
ctx.throw(500, msg)
}
}
export const serveBuilderPreview = async function (
ctx: Ctx<void, ServeBuilderPreviewResponse>
) {
const db = context.getWorkspaceDB({ skip_setup: true })
const appInfo = await db.get<Workspace>(DocumentType.WORKSPACE_METADATA)
if (!env.isJest()) {
let appId = context.getWorkspaceId()
const templateLoc = join(__dirname, "templates")
const previewLoc = fs.existsSync(templateLoc) ? templateLoc : __dirname
const previewHbs = loadHandlebarsFile(join(previewLoc, "preview.hbs"))
const nonce = ctx.state.nonce || ""
const addAppScripts =
ctx?.user?.license?.features?.includes(Feature.CUSTOM_APP_SCRIPTS) ||
false
let props: any = {
clientLibPath: await objectStore.clientLibraryUrl(
appId!,
appInfo.version
),
nonce,
}
// Add custom app scripts if enabled
if (addAppScripts) {
props.headAppScripts = getAppScriptHTML(appInfo, "Head", nonce)
props.bodyAppScripts = getAppScriptHTML(appInfo, "Body", nonce)
}
ctx.body = await processString(previewHbs, props)
} else {
// just return the app info for jest to assert on
ctx.body = { ...appInfo, builderPreview: true }
}
}
function serveLocalFile(ctx: Ctx, fileName: string) {
const tsPath = join(require.resolve("@budibase/client"), "..")
let rootPath = join(NODE_MODULES_PATH, "@budibase", "client", "dist")
return send(ctx, fileName, {
root: !fs.existsSync(rootPath) ? tsPath : rootPath,
})
}
export const serveClientLibrary = async function (
ctx: Ctx<void, ServeClientLibraryResponse>
) {
const workspaceId = context.getWorkspaceId()
if (!workspaceId) {
ctx.throw(400, "No workspace ID provided - cannot fetch client library.")
}
const serveLocally = await shouldServeLocally()
if (!serveLocally) {
const { stream } = await objectStore.getReadStream(
ObjectStoreBuckets.APPS,
objectStore.clientLibraryPath(workspaceId!)
)
ctx.body = stream
ctx.set("Content-Type", "application/javascript")
} else {
return serveLocalFile(ctx, "budibase-client.js")
}
}
export const serve3rdPartyFile = async function (ctx: Ctx) {
const { file } = ctx.params
const workspaceId = context.getWorkspaceId()
if (!workspaceId) {
ctx.throw(400, "No workspace ID provided - cannot fetch client library.")
}
const serveLocally = await shouldServeLocally()
if (!serveLocally) {
const { stream, contentType } = await objectStore.getReadStream(
ObjectStoreBuckets.APPS,
objectStore.client3rdPartyLibrary(workspaceId, file)
)
if (contentType) {
ctx.set("Content-Type", contentType)
}
ctx.body = stream
} else {
return serveLocalFile(ctx, file)
}
}
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>
) {
// Ensure datasource is valid
let datasource
try {
const { datasourceId } = ctx.params
datasource = await sdk.datasources.get(datasourceId, { enriched: true })
if (!datasource) {
ctx.throw(400, "The specified datasource could not be found")
}
} catch (error) {
ctx.throw(400, "The specified datasource could not be found")
}
// Determine type of datasource and generate signed URL
let signedUrl
let publicUrl
const awsRegion = (datasource?.config?.region || "eu-west-1") as string
if (datasource?.source === "S3") {
const { bucket, key } = ctx.request.body || {}
if (!bucket || !key) {
ctx.throw(400, "bucket and key values are required")
}
try {
let endpoint = datasource?.config?.endpoint
if (endpoint && !utils.urlHasProtocol(endpoint)) {
endpoint = `https://${endpoint}`
}
const s3 = new S3({
region: awsRegion,
endpoint: endpoint,
credentials: {
accessKeyId: datasource?.config?.accessKeyId as string,
secretAccessKey: datasource?.config?.secretAccessKey as string,
},
})
const params = { Bucket: bucket, Key: key }
signedUrl = await getSignedUrl(s3, new PutObjectCommand(params))
if (endpoint) {
publicUrl = `${endpoint}/${bucket}/${key}`
} else {
publicUrl = `https://${bucket}.s3.${awsRegion}.amazonaws.com/${key}`
}
} catch (error: any) {
ctx.throw(400, error)
}
}
ctx.body = { signedUrl, publicUrl }
}
export async function servePwaManifest(ctx: UserCtx<void, any>) {
const appId = context.getWorkspaceId()
if (!appId) {
ctx.throw(404)
}
try {
const db = context.getWorkspaceDB({ skip_setup: true })
const appInfo = await db.get<Workspace>(DocumentType.WORKSPACE_METADATA)
if (!appInfo.pwa) {
ctx.throw(404)
}
const manifest: PWAManifest = {
name: appInfo.pwa.name || appInfo.name,
scope: `/app${appInfo.url}`,
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" }
}
}