Skip to content

Commit

Permalink
Support attachment columns in Automations (#13567)
Browse files Browse the repository at this point in the history
* base work to support attachments in create / update row

* handle single attachment column

* fix tests

* pro

* fix some types

* handle case where file exists in storage

* improve attacment processing

* refactor slightly and ensure correct url is used for existing attachments

* add test

* Fixing a build issue.

* update tests

* some lint

* remove cursed backend-core test util

* addressing pr comments

* refactoring nasty automationUtils upload code

* remove uneeded check

* use basneeame for fallback filename

* add a test to ensure coverage of single attachment column type

* fail early when fetching object metadata

---------

Co-authored-by: mike12345567 <me@michaeldrury.co.uk>
  • Loading branch information
PClmnt and mike12345567 committed May 9, 2024
1 parent 90d9c8e commit db273bc
Show file tree
Hide file tree
Showing 16 changed files with 443 additions and 66 deletions.
50 changes: 37 additions & 13 deletions packages/backend-core/src/objectStore/objectStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ import { bucketTTLConfig, budibaseTempDir } from "./utils"
import { v4 } from "uuid"
import { APP_PREFIX, APP_DEV_PREFIX } from "../db"
import fsp from "fs/promises"
import { HeadObjectOutput } from "aws-sdk/clients/s3"

const streamPipeline = promisify(stream.pipeline)
// use this as a temporary store of buckets that are being created
const STATE = {
bucketCreationPromises: {},
}
const signedFilePrefix = "/files/signed"
export const SIGNED_FILE_PREFIX = "/files/signed"

type ListParams = {
ContinuationToken?: string
Expand All @@ -40,8 +41,13 @@ type UploadParams = BaseUploadParams & {
path?: string | PathLike
}

type StreamUploadParams = BaseUploadParams & {
stream: ReadStream
export type StreamTypes =
| ReadStream
| NodeJS.ReadableStream
| ReadableStream<Uint8Array>

export type StreamUploadParams = BaseUploadParams & {
stream?: StreamTypes
}

const CONTENT_TYPE_MAP: any = {
Expand Down Expand Up @@ -174,11 +180,9 @@ export async function upload({
const objectStore = ObjectStore(bucketName)
const bucketCreated = await createBucketIfNotExists(objectStore, bucketName)

if (ttl && (bucketCreated.created || bucketCreated.exists)) {
if (ttl && bucketCreated.created) {
let ttlConfig = bucketTTLConfig(bucketName, ttl)
if (objectStore.putBucketLifecycleConfiguration) {
await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise()
}
await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise()
}

let contentType = type
Expand Down Expand Up @@ -222,11 +226,9 @@ export async function streamUpload({
const objectStore = ObjectStore(bucketName)
const bucketCreated = await createBucketIfNotExists(objectStore, bucketName)

if (ttl && (bucketCreated.created || bucketCreated.exists)) {
if (ttl && bucketCreated.created) {
let ttlConfig = bucketTTLConfig(bucketName, ttl)
if (objectStore.putBucketLifecycleConfiguration) {
await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise()
}
await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise()
}

// Set content type for certain known extensions
Expand Down Expand Up @@ -333,7 +335,7 @@ export function getPresignedUrl(
const signedUrl = new URL(url)
const path = signedUrl.pathname
const query = signedUrl.search
return `${signedFilePrefix}${path}${query}`
return `${SIGNED_FILE_PREFIX}${path}${query}`
}
}

Expand Down Expand Up @@ -521,6 +523,26 @@ export async function getReadStream(
return client.getObject(params).createReadStream()
}

export async function getObjectMetadata(
bucket: string,
path: string
): Promise<HeadObjectOutput> {
bucket = sanitizeBucket(bucket)
path = sanitizeKey(path)

const client = ObjectStore(bucket)
const params = {
Bucket: bucket,
Key: path,
}

try {
return await client.headObject(params).promise()
} catch (err: any) {
throw new Error("Unable to retrieve metadata from object")
}
}

/*
Given a signed url like '/files/signed/tmp-files-attachments/app_123456/myfile.txt' extract
the bucket and the path from it
Expand All @@ -530,7 +552,9 @@ export function extractBucketAndPath(
): { bucket: string; path: string } | null {
const baseUrl = url.split("?")[0]

const regex = new RegExp(`^${signedFilePrefix}/(?<bucket>[^/]+)/(?<path>.+)$`)
const regex = new RegExp(
`^${SIGNED_FILE_PREFIX}/(?<bucket>[^/]+)/(?<path>.+)$`
)
const match = baseUrl.match(regex)

if (match && match.groups) {
Expand Down
56 changes: 54 additions & 2 deletions packages/backend-core/src/objectStore/utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { join } from "path"
import path, { join } from "path"
import { tmpdir } from "os"
import fs from "fs"
import env from "../environment"
import { PutBucketLifecycleConfigurationRequest } from "aws-sdk/clients/s3"

import * as objectStore from "./objectStore"
import {
AutomationAttachment,
AutomationAttachmentContent,
BucketedContent,
} from "@budibase/types"
/****************************************************
* NOTE: When adding a new bucket - name *
* sure that S3 usages (like budibase-infra) *
Expand Down Expand Up @@ -55,3 +60,50 @@ export const bucketTTLConfig = (

return params
}

async function processUrlAttachment(
attachment: AutomationAttachment
): Promise<AutomationAttachmentContent> {
const response = await fetch(attachment.url)
if (!response.ok || !response.body) {
throw new Error(`Unexpected response ${response.statusText}`)
}
const fallbackFilename = path.basename(new URL(attachment.url).pathname)
return {
filename: attachment.filename || fallbackFilename,
content: response.body,
}
}

export async function processObjectStoreAttachment(
attachment: AutomationAttachment
): Promise<BucketedContent> {
const result = objectStore.extractBucketAndPath(attachment.url)

if (result === null) {
throw new Error("Invalid signed URL")
}

const { bucket, path: objectPath } = result
const readStream = await objectStore.getReadStream(bucket, objectPath)
const fallbackFilename = path.basename(objectPath)
return {
bucket,
path: objectPath,
filename: attachment.filename || fallbackFilename,
content: readStream,
}
}

export async function processAutomationAttachment(
attachment: AutomationAttachment
): Promise<AutomationAttachmentContent | BucketedContent> {
const isFullyFormedUrl =
attachment.url?.startsWith("http://") ||
attachment.url?.startsWith("https://")
if (isFullyFormedUrl) {
return await processUrlAttachment(attachment)
} else {
return await processObjectStoreAttachment(attachment)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,8 @@
value.customType !== "cron" &&
value.customType !== "triggerSchema" &&
value.customType !== "automationFields" &&
value.type !== "attachment"
value.type !== "attachment" &&
value.type !== "attachment_single"
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import { tables } from "stores/builder"
import { Select, Checkbox, Label } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { FieldType } from "@budibase/types"
import RowSelectorTypes from "./RowSelectorTypes.svelte"
import DrawerBindableSlot from "../../common/bindings/DrawerBindableSlot.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
Expand All @@ -14,7 +16,6 @@
export let bindings
export let isTestModal
export let isUpdateRow
$: parsedBindings = bindings.map(binding => {
let clone = Object.assign({}, binding)
clone.icon = "ShareAndroid"
Expand All @@ -26,15 +27,19 @@
$: {
table = $tables.list.find(table => table._id === value?.tableId)
schemaFields = Object.entries(table?.schema ?? {})
// surface the schema so the user can see it in the json
schemaFields.map(([, schema]) => {
// Just sorting attachment types to the bottom here for a cleaner UX
schemaFields = Object.entries(table?.schema ?? {}).sort(
([, schemaA], [, schemaB]) =>
(schemaA.type === "attachment") - (schemaB.type === "attachment")
)
schemaFields.forEach(([, schema]) => {
if (!schema.autocolumn && !value[schema.name]) {
value[schema.name] = ""
}
})
}
const onChangeTable = e => {
value["tableId"] = e.detail
dispatch("change", value)
Expand Down Expand Up @@ -114,10 +119,16 @@
</div>
{#if schemaFields.length}
{#each schemaFields as [field, schema]}
{#if !schema.autocolumn && schema.type !== "attachment"}
<div class="schema-fields">
{#if !schema.autocolumn}
<div
class:schema-fields={schema.type !== FieldType.ATTACHMENTS &&
schema.type !== FieldType.ATTACHMENT_SINGLE}
>
<Label>{field}</Label>
<div class="field-width">
<div
class:field-width={schema.type !== FieldType.ATTACHMENTS &&
schema.type !== FieldType.ATTACHMENT_SINGLE}
>
{#if isTestModal}
<RowSelectorTypes
{isTestModal}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
<script>
import { Select, DatePicker, Multiselect, TextArea } from "@budibase/bbui"
import { FieldType } from "@budibase/types"
import LinkedRowSelector from "components/common/LinkedRowSelector.svelte"
import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte"
import ModalBindableInput from "../../common/bindings/ModalBindableInput.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
import Editor from "components/integration/QueryEditor.svelte"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
export let onChange
export let field
Expand All @@ -22,6 +24,27 @@
function schemaHasOptions(schema) {
return !!schema.constraints?.inclusion?.length
}
const handleAttachmentParams = keyValuObj => {
let params = {}
if (
schema.type === FieldType.ATTACHMENT_SINGLE &&
Object.keys(keyValuObj).length === 0
) {
return []
}
if (!Array.isArray(keyValuObj)) {
keyValuObj = [keyValuObj]
}
if (keyValuObj.length) {
for (let param of keyValuObj) {
params[param.url] = param.filename
}
}
return params
}
</script>
{#if schemaHasOptions(schema) && schema.type !== "array"}
Expand Down Expand Up @@ -77,6 +100,35 @@
on:change={e => onChange(e, field)}
useLabel={false}
/>
{:else if schema.type === FieldType.ATTACHMENTS || schema.type === FieldType.ATTACHMENT_SINGLE}
<div class="attachment-field-spacinng">
<KeyValueBuilder
on:change={e =>
onChange(
{
detail:
schema.type === FieldType.ATTACHMENT_SINGLE
? e.detail.length > 0
? { url: e.detail[0].name, filename: e.detail[0].value }
: {}
: e.detail.map(({ name, value }) => ({
url: name,
filename: value,
})),
},
field
)}
object={handleAttachmentParams(value[field])}
allowJS
{bindings}
keyBindings
customButtonText={"Add attachment"}
keyPlaceholder={"URL"}
valuePlaceholder={"Filename"}
actionButtonDisabled={schema.type === FieldType.ATTACHMENT_SINGLE &&
Object.keys(value[field]).length >= 1}
/>
</div>
{:else if ["string", "number", "bigint", "barcodeqr", "array"].includes(schema.type)}
<svelte:component
this={isTestModal ? ModalBindableInput : DrawerBindableInput}
Expand All @@ -90,3 +142,10 @@
title={schema.name}
/>
{/if}
<style>
.attachment-field-spacinng {
margin-top: var(--spacing-s);
margin-bottom: var(--spacing-l);
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "dataBinding"
import { FieldType } from "@budibase/types"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { createEventDispatcher, setContext } from "svelte"
Expand Down Expand Up @@ -102,6 +103,8 @@
longform: value => !isJSBinding(value),
json: value => !isJSBinding(value),
boolean: isValidBoolean,
attachment: false,
attachment_single: false,
}
const isValid = value => {
Expand All @@ -116,7 +119,16 @@
if (type === "json" && !isJSBinding(value)) {
return "json-slot-icon"
}
if (!["string", "number", "bigint", "barcodeqr"].includes(type)) {
if (
![
"string",
"number",
"bigint",
"barcodeqr",
"attachment",
"attachment_single",
].includes(type)
) {
return "slot-icon"
}
return ""
Expand Down Expand Up @@ -157,7 +169,7 @@
{updateOnChange}
/>
{/if}
{#if !disabled && type !== "formula"}
{#if !disabled && type !== "formula" && !disabled && type !== FieldType.ATTACHMENTS && !disabled && type !== FieldType.ATTACHMENT_SINGLE}
<div
class={`icon ${getIconClass(value, type)}`}
on:click={() => {
Expand Down

0 comments on commit db273bc

Please sign in to comment.