Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support attachment columns in Automations #13567

Merged
merged 25 commits into from
May 9, 2024
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
735e10b
base work to support attachments in create / update row
PClmnt Apr 25, 2024
9cf3ff4
handle single attachment column
PClmnt Apr 26, 2024
9a893d0
Merge remote-tracking branch 'origin/master' into feat/support-attach…
PClmnt Apr 26, 2024
c328c02
fix tests
PClmnt Apr 26, 2024
70658ce
pro
PClmnt Apr 26, 2024
e7f650c
fix some types
PClmnt Apr 26, 2024
69de016
handle case where file exists in storage
PClmnt Apr 26, 2024
52fb1af
improve attacment processing
PClmnt Apr 26, 2024
3788f7b
refactor slightly and ensure correct url is used for existing attachm…
PClmnt May 3, 2024
58e7853
Merge remote-tracking branch 'origin/master' into feat/support-attach…
PClmnt May 7, 2024
3cf12fe
add test
PClmnt May 8, 2024
c52610c
Fixing a build issue.
mike12345567 May 8, 2024
0c7e5e9
Merge remote-tracking branch 'origin/master' into feat/support-attach…
PClmnt May 8, 2024
bf0e926
update tests
PClmnt May 8, 2024
4db4db8
some lint
PClmnt May 8, 2024
2b7c21e
Merge remote-tracking branch 'origin/master' into feat/support-attach…
PClmnt May 8, 2024
6617a96
remove cursed backend-core test util
PClmnt May 8, 2024
eb6c02b
addressing pr comments
PClmnt May 9, 2024
f42dc82
refactoring nasty automationUtils upload code
PClmnt May 9, 2024
04908ed
remove uneeded check
PClmnt May 9, 2024
dfc5606
use basneeame for fallback filename
PClmnt May 9, 2024
3fd2f96
add a test to ensure coverage of single attachment column type
PClmnt May 9, 2024
afc59b1
Merge remote-tracking branch 'origin/master' into feat/support-attach…
PClmnt May 9, 2024
d1ea38f
fail early when fetching object metadata
PClmnt May 9, 2024
e685775
Merge branch 'master' into feat/support-attachments-in-automations
PClmnt May 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
48 changes: 35 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()
PClmnt marked this conversation as resolved.
Show resolved Hide resolved
}

// 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,24 @@ 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,
}

const metadata = await client.headObject(params).promise()

return metadata
}

/*
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 +550,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) {
throw new Error(`Unexpected response ${response.statusText}`)
}
const fallbackFilename = path.basename(new URL(attachment.url).pathname)
return {
filename: attachment.filename || fallbackFilename,
content: response.body!,
}
PClmnt marked this conversation as resolved.
Show resolved Hide resolved
}

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
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
export let customButtonText = null
export let keyBindings = false
export let allowJS = false
export let actionButtonDisabled = false
export let compare = (option, value) => option === value

let fields = Object.entries(object || {}).map(([name, value]) => ({
Expand Down Expand Up @@ -189,7 +190,14 @@
{/if}
{#if !readOnly && !noAddButton}
<div>
<ActionButton icon="Add" secondary thin outline on:click={addEntry}>
<ActionButton
disabled={actionButtonDisabled}
icon="Add"
secondary
thin
outline
on:click={addEntry}
>
{#if customButtonText}
{customButtonText}
{:else}
Expand Down