UNPKG

@budibase/server

Version:
556 lines (492 loc) • 17.4 kB
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" } } }