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

REST file handling and SMTP automation block attachments #13403

Merged
merged 86 commits into from
Apr 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
86 commits
Select commit Hold shift + click to select a range
5fc4da8
handle files in rest connector
PClmnt Mar 12, 2024
cf82ef0
fetch presigned url and return
PClmnt Mar 13, 2024
f47bdaa
further updates to handle files in rest connector
PClmnt Mar 13, 2024
3e84622
remove unused important and fix extension bug
PClmnt Mar 13, 2024
0a901e4
wrong expiry param
PClmnt Mar 13, 2024
d473618
tests
PClmnt Mar 14, 2024
24d1e4a
add const for temp bucket
PClmnt Mar 14, 2024
69f469b
handle ttl on bucket
PClmnt Mar 14, 2024
0d2c8c4
more bucket ttl work
PClmnt Mar 14, 2024
6176714
split out fileresponse and xmlresponse into utils
PClmnt Mar 14, 2024
82296d4
lint
PClmnt Mar 14, 2024
9e2cc93
remove log
PClmnt Mar 14, 2024
d4b162a
fix tests
PClmnt Mar 14, 2024
8662b1f
some pr comments
PClmnt Mar 14, 2024
4d0ca28
update function naming and lint
PClmnt Mar 14, 2024
bcd3c0a
adding back needed response for frontend
PClmnt Mar 14, 2024
aa89f07
use fsp
PClmnt Mar 14, 2024
c9d88e7
handle different content-disposition and potential path traversal
PClmnt Mar 15, 2024
04895fc
add test container for s3 / minio
PClmnt Mar 15, 2024
43308ad
add test case for filename* and ascii filenames
PClmnt Mar 15, 2024
61f0f5b
move tests into separate describe
PClmnt Mar 15, 2024
c0e17dd
remove log
PClmnt Mar 15, 2024
3c99d35
up timeout
PClmnt Mar 15, 2024
b1ec582
switch to minio image instead of localstack
PClmnt Mar 15, 2024
0e81191
use minio image instead of s3 for testing
PClmnt Mar 15, 2024
7016f6b
stream file upload instead
PClmnt Mar 15, 2024
8534cb8
use streamUpload and update signatures
PClmnt Mar 18, 2024
b7cda9b
update bucketcreate return
PClmnt Mar 18, 2024
59a76b7
throw real error
PClmnt Mar 18, 2024
35f62f9
tidy up
PClmnt Mar 18, 2024
1e4ca5d
pro
PClmnt Mar 18, 2024
e115a10
pro ref fix?
PClmnt Mar 19, 2024
ec5c0fd
Merge branch 'refs/heads/feat/automations-and-rest-file-handling' int…
PClmnt Mar 19, 2024
527c0b1
pro fix
PClmnt Mar 19, 2024
95aa7ad
pro fix?
PClmnt Mar 19, 2024
e2ae404
Merge branch 'refs/heads/feat/automations-and-rest-file-handling' int…
PClmnt Mar 19, 2024
f60375b
Merge pull request #13247 from Budibase/feat/handle-files-rest
PClmnt Mar 19, 2024
040446c
move minio test provider to backend-core
PClmnt Mar 25, 2024
40d575e
update email builder to allow attachments
PClmnt Mar 25, 2024
99f2b3e
testing for sending files via smtp
PClmnt Mar 25, 2024
b3c76f3
use backend-core minio test container in server
PClmnt Mar 25, 2024
e1400d2
handle different types of url
PClmnt Mar 25, 2024
5b3cb06
fix minio test provider
PClmnt Mar 25, 2024
39cd39e
test with container host
PClmnt Mar 25, 2024
c068ed9
lint
PClmnt Mar 25, 2024
cfefdb8
try different hostname?
PClmnt Mar 26, 2024
57489bd
Revert "try different hostname?"
PClmnt Mar 26, 2024
d61d433
fix issue with fetching of signed url with test minio
PClmnt Mar 27, 2024
22ecdf4
update autoamtion attachments to take filename and url
PClmnt Mar 28, 2024
80b7a68
fix tests
PClmnt Mar 28, 2024
5cc887d
Merge remote-tracking branch 'origin/master' into feat/automations-an…
PClmnt Mar 28, 2024
ac16687
Merge branch 'feat/automations-and-rest-file-handling' into feat/smtp…
PClmnt Mar 28, 2024
64eb419
pro ref
PClmnt Mar 28, 2024
1800bd6
fix parsing of url object
PClmnt Apr 2, 2024
429a4c7
Merge remote-tracking branch 'origin/master' into feat/automations-an…
PClmnt Apr 3, 2024
c8efff2
Merge branch 'feat/automations-and-rest-file-handling' into feat/smtp…
PClmnt Apr 3, 2024
94cde37
pr comments and linting
PClmnt Apr 3, 2024
a571e9c
pro ref
PClmnt Apr 3, 2024
6c10261
fix pro again
PClmnt Apr 3, 2024
e0f90e6
fix pro
PClmnt Apr 3, 2024
f2df70e
fix pro for real this time
PClmnt Apr 3, 2024
e3cd1fb
Merge remote-tracking branch 'origin/master' into feat/automations-an…
PClmnt Apr 4, 2024
8f5735c
Merge branch 'feat/automations-and-rest-file-handling' into feat/smtp…
PClmnt Apr 4, 2024
0af4e73
account-portal
PClmnt Apr 4, 2024
1b818ec
Merge pull request #13351 from Budibase/feat/smtp-file-handling
PClmnt Apr 4, 2024
6ff3944
fix null issue
PClmnt Apr 5, 2024
b757bc3
Merge remote-tracking branch 'origin/master' into feat/automations-an…
PClmnt Apr 5, 2024
24f5631
Merge remote-tracking branch 'origin/master' into feat/automations-an…
PClmnt Apr 5, 2024
004fb2f
Merge remote-tracking branch 'origin/master' into feat/automations-an…
PClmnt Apr 8, 2024
485e17a
Merge remote-tracking branch 'origin/master' into feat/automations-an…
PClmnt Apr 8, 2024
ce2d117
fix ref
PClmnt Apr 8, 2024
c7204d3
ref
PClmnt Apr 8, 2024
045eb53
When sending a file attachment in email fetch it directly from our ob…
PClmnt Apr 15, 2024
f67877b
add more checks to ensure we're working with a signed url
PClmnt Apr 16, 2024
2edff4c
update test to account for direct object store read
PClmnt Apr 16, 2024
ae7bf10
formatting
PClmnt Apr 16, 2024
4123aa7
fix time issues within test
PClmnt Apr 17, 2024
91e5e73
update bucket and path extraction to regex
PClmnt Apr 18, 2024
16ecc9d
use const in regex
PClmnt Apr 18, 2024
9c9ce7e
Merge pull request #13492 from Budibase/feat/fetch-email-attachment-f…
PClmnt Apr 18, 2024
80a9165
Merge remote-tracking branch 'origin/master' into feat/automations-an…
PClmnt Apr 19, 2024
166fa9c
pro
PClmnt Apr 19, 2024
4c7ccc6
Updating TTL handling in upload functions (#13539)
PClmnt Apr 22, 2024
b8d4cff
Merge remote-tracking branch 'origin/master' into feat/automations-an…
PClmnt Apr 22, 2024
bcf0d15
pro
PClmnt Apr 22, 2024
1b4c7a0
pro
PClmnt Apr 22, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/backend-core/src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const DefaultBucketName = {
TEMPLATES: "templates",
GLOBAL: "global",
PLUGINS: "plugins",
TEMP: "tmp-file-attachments",
}

const selfHosted = !!parseInt(process.env.SELF_HOSTED || "")
Expand Down Expand Up @@ -146,6 +147,7 @@ const environment = {
process.env.GLOBAL_BUCKET_NAME || DefaultBucketName.GLOBAL,
PLUGIN_BUCKET_NAME:
process.env.PLUGIN_BUCKET_NAME || DefaultBucketName.PLUGINS,
TEMP_BUCKET_NAME: process.env.TEMP_BUCKET_NAME || DefaultBucketName.TEMP,
USE_COUCH: process.env.USE_COUCH || true,
MOCK_REDIS: process.env.MOCK_REDIS,
DEFAULT_LICENSE: process.env.DEFAULT_LICENSE,
Expand Down
121 changes: 97 additions & 24 deletions packages/backend-core/src/objectStore/objectStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,41 @@ import tar from "tar-fs"
import zlib from "zlib"
import { promisify } from "util"
import { join } from "path"
import fs, { ReadStream } from "fs"
import fs, { PathLike, ReadStream } from "fs"
import env from "../environment"
import { budibaseTempDir } from "./utils"
import { bucketTTLConfig, budibaseTempDir } from "./utils"
import { v4 } from "uuid"
import { APP_PREFIX, APP_DEV_PREFIX } from "../db"
import fsp from "fs/promises"

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

type ListParams = {
ContinuationToken?: string
}

type UploadParams = {
type BaseUploadParams = {
bucket: string
filename: string
path: string
type?: string | null
// can be undefined, we will remove it
metadata?: {
[key: string]: string | undefined
}
metadata?: { [key: string]: string | undefined }
body?: ReadableStream | Buffer
ttl?: number
addTTL?: boolean
PClmnt marked this conversation as resolved.
Show resolved Hide resolved
extra?: any
}

type UploadParams = BaseUploadParams & {
path?: string | PathLike
}

type StreamUploadParams = BaseUploadParams & {
stream: ReadStream
}

const CONTENT_TYPE_MAP: any = {
Expand All @@ -41,6 +51,8 @@ const CONTENT_TYPE_MAP: any = {
js: "application/javascript",
json: "application/json",
gz: "application/gzip",
svg: "image/svg+xml",
form: "multipart/form-data",
}

const STRING_CONTENT_TYPES = [
Expand Down Expand Up @@ -105,37 +117,43 @@ export function ObjectStore(
* Given an object store and a bucket name this will make sure the bucket exists,
* if it does not exist then it will create it.
*/
export async function makeSureBucketExists(client: any, bucketName: string) {
export async function createBucketIfNotExists(
client: any,
bucketName: string
): Promise<{ created: boolean; exists: boolean }> {
bucketName = sanitizeBucket(bucketName)
try {
await client
.headBucket({
Bucket: bucketName,
})
.promise()
return { created: false, exists: true }
} catch (err: any) {
const promises: any = STATE.bucketCreationPromises
const doesntExist = err.statusCode === 404,
noAccess = err.statusCode === 403
if (promises[bucketName]) {
await promises[bucketName]
return { created: false, exists: true }
} else if (doesntExist || noAccess) {
if (doesntExist) {
// bucket doesn't exist create it
promises[bucketName] = client
.createBucket({
Bucket: bucketName,
})
.promise()
await promises[bucketName]
delete promises[bucketName]
return { created: true, exists: false }
} else {
throw new Error("Access denied to object store bucket." + err)
}
} else {
throw new Error("Unable to write to object store bucket.")
}
}
}

/**
* Uploads the contents of a file given the required parameters, useful when
* temp files in use (for example file uploaded as an attachment).
Expand All @@ -146,12 +164,22 @@ export async function upload({
path,
type,
metadata,
body,
ttl,
}: UploadParams) {
const extension = filename.split(".").pop()
const fileBytes = fs.readFileSync(path)

const fileBytes = path ? (await fsp.open(path)).createReadStream() : body

const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(objectStore, bucketName)
const bucketCreated = await createBucketIfNotExists(objectStore, bucketName)

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

let contentType = type
if (!contentType) {
Expand All @@ -174,21 +202,32 @@ export async function upload({
}
config.Metadata = metadata
}

return objectStore.upload(config).promise()
}

/**
* Similar to the upload function but can be used to send a file stream
* through to the object store.
*/
export async function streamUpload(
bucketName: string,
filename: string,
stream: ReadStream | ReadableStream,
extra = {}
) {
export async function streamUpload({
bucket: bucketName,
stream,
filename,
type,
extra,
ttl,
}: StreamUploadParams) {
const extension = filename.split(".").pop()
const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(objectStore, bucketName)
const bucketCreated = await createBucketIfNotExists(objectStore, bucketName)

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

// Set content type for certain known extensions
if (filename?.endsWith(".js")) {
Expand All @@ -203,10 +242,18 @@ export async function streamUpload(
}
}

let contentType = type
if (!contentType) {
contentType = extension
? CONTENT_TYPE_MAP[extension.toLowerCase()]
: CONTENT_TYPE_MAP.txt
}

const params = {
Bucket: sanitizeBucket(bucketName),
Key: sanitizeKey(filename),
Body: stream,
ContentType: contentType,
...extra,
}
return objectStore.upload(params).promise()
Expand Down Expand Up @@ -286,7 +333,7 @@ export function getPresignedUrl(
const signedUrl = new URL(url)
const path = signedUrl.pathname
const query = signedUrl.search
return `/files/signed${path}${query}`
return `${signedFilePrefix}${path}${query}`
}
}

Expand Down Expand Up @@ -341,7 +388,7 @@ export async function retrieveDirectory(bucketName: string, path: string) {
*/
export async function deleteFile(bucketName: string, filepath: string) {
const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(objectStore, bucketName)
await createBucketIfNotExists(objectStore, bucketName)
const params = {
Bucket: bucketName,
Key: sanitizeKey(filepath),
Expand All @@ -351,7 +398,7 @@ export async function deleteFile(bucketName: string, filepath: string) {

export async function deleteFiles(bucketName: string, filepaths: string[]) {
const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(objectStore, bucketName)
await createBucketIfNotExists(objectStore, bucketName)
const params = {
Bucket: bucketName,
Delete: {
Expand Down Expand Up @@ -412,7 +459,13 @@ export async function uploadDirectory(
if (file.isDirectory()) {
uploads.push(uploadDirectory(bucketName, local, path))
} else {
uploads.push(streamUpload(bucketName, path, fs.createReadStream(local)))
uploads.push(
streamUpload({
bucket: bucketName,
filename: path,
stream: fs.createReadStream(local),
})
)
}
}
await Promise.all(uploads)
Expand Down Expand Up @@ -467,3 +520,23 @@ export async function getReadStream(
}
return client.getObject(params).createReadStream()
}

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

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

if (match && match.groups) {
const { bucket, path } = match.groups
return { bucket, path }
}

return null
}
26 changes: 26 additions & 0 deletions packages/backend-core/src/objectStore/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { join } from "path"
import { tmpdir } from "os"
import fs from "fs"
import env from "../environment"
import { PutBucketLifecycleConfigurationRequest } from "aws-sdk/clients/s3"

/****************************************************
* NOTE: When adding a new bucket - name *
Expand All @@ -15,6 +16,7 @@ export const ObjectStoreBuckets = {
TEMPLATES: env.TEMPLATES_BUCKET_NAME,
GLOBAL: env.GLOBAL_BUCKET_NAME,
PLUGINS: env.PLUGIN_BUCKET_NAME,
TEMP: env.TEMP_BUCKET_NAME,
}

const bbTmp = join(tmpdir(), ".budibase")
Expand All @@ -29,3 +31,27 @@ try {
export function budibaseTempDir() {
return bbTmp
}

export const bucketTTLConfig = (
bucketName: string,
days: number
): PutBucketLifecycleConfigurationRequest => {
const lifecycleRule = {
ID: `${bucketName}-ExpireAfter${days}days`,
Prefix: "",
Status: "Enabled",
Expiration: {
Days: days,
},
}
const lifecycleConfiguration = {
Rules: [lifecycleRule],
}

const params = {
Bucket: bucketName,
LifecycleConfiguration: lifecycleConfiguration,
}

return params
}
3 changes: 3 additions & 0 deletions packages/backend-core/tests/core/utilities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ export { generator } from "./structures"
export * as testContainerUtils from "./testContainerUtils"
export * as utils from "./utils"
export * from "./jestUtils"
import * as minio from "./minio"

export const objectStoreTestProviders = { minio }
34 changes: 34 additions & 0 deletions packages/backend-core/tests/core/utilities/minio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { GenericContainer, Wait, StartedTestContainer } from "testcontainers"
import { AbstractWaitStrategy } from "testcontainers/build/wait-strategies/wait-strategy"
import env from "../../../src/environment"

let container: StartedTestContainer | undefined

class ObjectStoreWaitStrategy extends AbstractWaitStrategy {
async waitUntilReady(container: any, boundPorts: any, startTime?: Date) {
const logs = Wait.forListeningPorts()
await logs.waitUntilReady(container, boundPorts, startTime)
}
}

export async function start(): Promise<void> {
container = await new GenericContainer("minio/minio")
.withExposedPorts(9000)
.withCommand(["server", "/data"])
.withEnvironment({
MINIO_ACCESS_KEY: "budibase",
MINIO_SECRET_KEY: "budibase",
})
.withWaitStrategy(new ObjectStoreWaitStrategy().withStartupTimeout(30000))
.start()

const port = container.getMappedPort(9000)
env._set("MINIO_URL", `http://0.0.0.0:${port}`)
adrinr marked this conversation as resolved.
Show resolved Hide resolved
}

export async function stop() {
if (container) {
await container.stop()
container = undefined
}
}
Loading
Loading