forked from tsai/budibase
23 changed files with 542 additions and 690 deletions
@ -0,0 +1,33 @@ |
|||||
|
<script> |
||||
|
import { Select, Label } from "@budibase/bbui" |
||||
|
import { currentAsset } from "builderStore" |
||||
|
import { findAllMatchingComponents } from "builderStore/componentUtils" |
||||
|
|
||||
|
export let parameters |
||||
|
|
||||
|
$: components = findAllMatchingComponents($currentAsset.props, component => |
||||
|
component._component.endsWith("s3upload") |
||||
|
) |
||||
|
</script> |
||||
|
|
||||
|
<div class="root"> |
||||
|
<Label small>S3 Upload Component</Label> |
||||
|
<Select |
||||
|
bind:value={parameters.componentId} |
||||
|
options={components} |
||||
|
getOptionLabel={x => x._instanceName} |
||||
|
getOptionValue={x => x._id} |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
.root { |
||||
|
display: grid; |
||||
|
column-gap: var(--spacing-l); |
||||
|
row-gap: var(--spacing-s); |
||||
|
grid-template-columns: 120px 1fr; |
||||
|
align-items: center; |
||||
|
max-width: 400px; |
||||
|
margin: 0 auto; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,15 @@ |
|||||
|
<script> |
||||
|
import { Select } from "@budibase/bbui" |
||||
|
import { datasources } from "stores/backend" |
||||
|
|
||||
|
export let value = null |
||||
|
|
||||
|
$: dataSources = $datasources.list |
||||
|
.filter(ds => ds.source === "S3" && !ds.config?.endpoint) |
||||
|
.map(ds => ({ |
||||
|
label: ds.name, |
||||
|
value: ds._id, |
||||
|
})) |
||||
|
</script> |
||||
|
|
||||
|
<Select options={dataSources} {value} on:change /> |
||||
@ -0,0 +1,143 @@ |
|||||
|
<script> |
||||
|
import Field from "./Field.svelte" |
||||
|
import { CoreDropzone, ProgressCircle } from "@budibase/bbui" |
||||
|
import { getContext, onMount, onDestroy } from "svelte" |
||||
|
|
||||
|
export let datasourceId |
||||
|
export let bucket |
||||
|
export let key |
||||
|
export let field |
||||
|
export let label |
||||
|
export let disabled = false |
||||
|
export let validation |
||||
|
|
||||
|
let fieldState |
||||
|
let fieldApi |
||||
|
|
||||
|
const { API, notificationStore, uploadStore } = getContext("sdk") |
||||
|
const component = getContext("component") |
||||
|
|
||||
|
// 5GB cap per item sent via S3 REST API |
||||
|
const MaxFileSize = 1000000000 * 5 |
||||
|
|
||||
|
// Actual file data to upload |
||||
|
let data |
||||
|
let loading = false |
||||
|
|
||||
|
const handleFileTooLarge = () => { |
||||
|
notificationStore.actions.warning( |
||||
|
"Files cannot exceed 5GB. Please try again with a smaller file." |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
// Process the file input and return a serializable structure expected by |
||||
|
// the dropzone component to display the file |
||||
|
const processFiles = async fileList => { |
||||
|
return await new Promise(resolve => { |
||||
|
if (!fileList?.length) { |
||||
|
return [] |
||||
|
} |
||||
|
|
||||
|
// Don't read in non-image files |
||||
|
data = fileList[0] |
||||
|
if (!data.type?.startsWith("image")) { |
||||
|
resolve([ |
||||
|
{ |
||||
|
name: data.name, |
||||
|
type: data.type, |
||||
|
}, |
||||
|
]) |
||||
|
} |
||||
|
|
||||
|
// Read image files and display as preview |
||||
|
const reader = new FileReader() |
||||
|
reader.addEventListener( |
||||
|
"load", |
||||
|
() => { |
||||
|
resolve([ |
||||
|
{ |
||||
|
url: reader.result, |
||||
|
name: data.name, |
||||
|
type: data.type, |
||||
|
}, |
||||
|
]) |
||||
|
}, |
||||
|
false |
||||
|
) |
||||
|
reader.readAsDataURL(fileList[0]) |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
const upload = async () => { |
||||
|
loading = true |
||||
|
try { |
||||
|
const res = await API.externalUpload(datasourceId, bucket, key, data) |
||||
|
notificationStore.actions.success("File uploaded successfully") |
||||
|
loading = false |
||||
|
return res |
||||
|
} catch (error) { |
||||
|
notificationStore.actions.error(`Error uploading file: ${error}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onMount(() => { |
||||
|
uploadStore.actions.registerFileUpload($component.id, upload) |
||||
|
}) |
||||
|
|
||||
|
onDestroy(() => { |
||||
|
uploadStore.actions.unregisterFileUpload($component.id) |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<Field |
||||
|
{label} |
||||
|
{field} |
||||
|
{disabled} |
||||
|
{validation} |
||||
|
type="s3upload" |
||||
|
bind:fieldState |
||||
|
bind:fieldApi |
||||
|
defaultValue={[]} |
||||
|
> |
||||
|
<div class="content"> |
||||
|
{#if fieldState} |
||||
|
<CoreDropzone |
||||
|
value={fieldState.value} |
||||
|
disabled={loading || fieldState.disabled} |
||||
|
error={fieldState.error} |
||||
|
on:change={e => { |
||||
|
fieldApi.setValue(e.detail) |
||||
|
}} |
||||
|
{processFiles} |
||||
|
{handleFileTooLarge} |
||||
|
maximum={1} |
||||
|
fileSizeLimit={MaxFileSize} |
||||
|
/> |
||||
|
{/if} |
||||
|
{#if loading} |
||||
|
<div class="overlay" /> |
||||
|
<div class="loading"> |
||||
|
<ProgressCircle /> |
||||
|
</div> |
||||
|
{/if} |
||||
|
</div> |
||||
|
</Field> |
||||
|
|
||||
|
<style> |
||||
|
.content { |
||||
|
position: relative; |
||||
|
} |
||||
|
.overlay, |
||||
|
.loading { |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
height: 100%; |
||||
|
width: 100%; |
||||
|
display: grid; |
||||
|
place-items: center; |
||||
|
} |
||||
|
.overlay { |
||||
|
background-color: var(--spectrum-global-color-gray-50); |
||||
|
opacity: 0.5; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,42 @@ |
|||||
|
import { writable, get } from "svelte/store" |
||||
|
|
||||
|
export const createUploadStore = () => { |
||||
|
const store = writable([]) |
||||
|
|
||||
|
// Registers a new file upload component
|
||||
|
const registerFileUpload = (componentId, callback) => { |
||||
|
if (!componentId || !callback) { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
store.update(state => { |
||||
|
state.push({ |
||||
|
componentId, |
||||
|
callback, |
||||
|
}) |
||||
|
return state |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
// Unregisters a file upload component
|
||||
|
const unregisterFileUpload = componentId => { |
||||
|
store.update(state => state.filter(c => c.componentId !== componentId)) |
||||
|
} |
||||
|
|
||||
|
// Processes a file upload for a given component ID
|
||||
|
const processFileUpload = async componentId => { |
||||
|
if (!componentId) { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
const component = get(store).find(c => c.componentId === componentId) |
||||
|
return await component?.callback() |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
subscribe: store.subscribe, |
||||
|
actions: { registerFileUpload, unregisterFileUpload, processFileUpload }, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export const uploadStore = createUploadStore() |
||||
File diff suppressed because it is too large
@ -0,0 +1,98 @@ |
|||||
|
jest.mock("node-fetch") |
||||
|
jest.mock("aws-sdk", () => ({ |
||||
|
config: { |
||||
|
update: jest.fn(), |
||||
|
}, |
||||
|
DynamoDB: { |
||||
|
DocumentClient: jest.fn(), |
||||
|
}, |
||||
|
S3: jest.fn(() => ({ |
||||
|
getSignedUrl: jest.fn(() => { |
||||
|
return "my-url" |
||||
|
}), |
||||
|
})), |
||||
|
})) |
||||
|
|
||||
|
const setup = require("./utilities") |
||||
|
|
||||
|
describe("/attachments", () => { |
||||
|
let request = setup.getRequest() |
||||
|
let config = setup.getConfig() |
||||
|
let app |
||||
|
|
||||
|
afterAll(setup.afterAll) |
||||
|
|
||||
|
beforeEach(async () => { |
||||
|
app = await config.init() |
||||
|
}) |
||||
|
|
||||
|
describe("generateSignedUrls", () => { |
||||
|
let datasource |
||||
|
|
||||
|
beforeEach(async () => { |
||||
|
datasource = await config.createDatasource({ |
||||
|
datasource: { |
||||
|
type: "datasource", |
||||
|
name: "Test", |
||||
|
source: "S3", |
||||
|
config: {}, |
||||
|
}, |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
it("should be able to generate a signed upload URL", async () => { |
||||
|
const bucket = "foo" |
||||
|
const key = "bar" |
||||
|
const res = await request |
||||
|
.post(`/api/attachments/${datasource._id}/url`) |
||||
|
.send({ bucket, key }) |
||||
|
.set(config.defaultHeaders()) |
||||
|
.expect("Content-Type", /json/) |
||||
|
.expect(200) |
||||
|
expect(res.body.signedUrl).toEqual("my-url") |
||||
|
expect(res.body.publicUrl).toEqual( |
||||
|
`https://${bucket}.s3.eu-west-1.amazonaws.com/${key}` |
||||
|
) |
||||
|
}) |
||||
|
|
||||
|
it("should handle an invalid datasource ID", async () => { |
||||
|
const res = await request |
||||
|
.post(`/api/attachments/foo/url`) |
||||
|
.send({ |
||||
|
bucket: "foo", |
||||
|
key: "bar", |
||||
|
}) |
||||
|
.set(config.defaultHeaders()) |
||||
|
.expect("Content-Type", /json/) |
||||
|
.expect(400) |
||||
|
expect(res.body.message).toEqual( |
||||
|
"The specified datasource could not be found" |
||||
|
) |
||||
|
}) |
||||
|
|
||||
|
it("should require a bucket parameter", async () => { |
||||
|
const res = await request |
||||
|
.post(`/api/attachments/${datasource._id}/url`) |
||||
|
.send({ |
||||
|
bucket: undefined, |
||||
|
key: "bar", |
||||
|
}) |
||||
|
.set(config.defaultHeaders()) |
||||
|
.expect("Content-Type", /json/) |
||||
|
.expect(400) |
||||
|
expect(res.body.message).toEqual("bucket and key values are required") |
||||
|
}) |
||||
|
|
||||
|
it("should require a key parameter", async () => { |
||||
|
const res = await request |
||||
|
.post(`/api/attachments/${datasource._id}/url`) |
||||
|
.send({ |
||||
|
bucket: "foo", |
||||
|
}) |
||||
|
.set(config.defaultHeaders()) |
||||
|
.expect("Content-Type", /json/) |
||||
|
.expect(400) |
||||
|
expect(res.body.message).toEqual("bucket and key values are required") |
||||
|
}) |
||||
|
}) |
||||
|
}) |
||||
Loading…
Reference in new issue