Skip to content

Commit

Permalink
feat(blob): add createCredentials() to support presigned URLs (#323)
Browse files Browse the repository at this point in the history
  • Loading branch information
atinux authored Oct 15, 2024
1 parent ec05b61 commit 6502032
Show file tree
Hide file tree
Showing 11 changed files with 403 additions and 10 deletions.
4 changes: 2 additions & 2 deletions docs/content/0.index.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ hero:
light: '/images/landing/hero-light.svg'
dark: '/images/landing/hero-dark.svg'
headline:
label: "Vectorize is out"
to: /changelog/hub-vectorize
label: "Blob: Presigned URLs"
to: /changelog/blob-presigned-urls
icon: i-ph-arrow-right
features:
- name: Cloud Hosting
Expand Down
162 changes: 162 additions & 0 deletions docs/content/1.docs/2.features/blob.md
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,68 @@ Returns a `BlobMultipartUpload`
::


### `createCredentials()`

Creates temporary access credentials that can be optionally scoped to prefixes or objects.

Useful to create presigned URLs to upload files to R2 from client-side ([see example](#create-presigned-urls-to-upload-files-to-r2)).

::note
This method is only available in production or in development with `--remote` flag.
::

```ts
// Create credentials with default permission & scope (admin-read-write)
const credentials = await hubBlob().createCredentials()

// Limit the scope to a specific object & permission
const credentials = await hubBlob().createCredentials({
permission: 'object-read-write',
pathnames: ['only-this-file.png']
})
```

Read more about [creating presigned URLs to upload files to R2](#create-presigned-urls-to-upload-files-to-r2).

#### Params

::field-group
::field{name="options" type="Object"}
The options to create the credentials.
::collapsible
::field{name="permission" type="string"}
The permission of the credentials, defaults to `'admin-read-write'`{lang=ts}.
```ts
'admin-read-write' | 'admin-read-only' | 'object-read-write' | 'object-read-only'
```
::
::field{name="ttl" type="number"}
The ttl of the credentials in seconds, defaults to `900`.
::
::field{name="pathnames" type="string[]"}
The pathnames to scope the credentials to.
::
::field{name="prefixes" type="string[]"}
The prefixes to scope the credentials to.
::
::
::
::

#### Return

Returns an object with the following properties:

```ts
{
accountId: string
bucketName: string
accessKeyId: string
secretAccessKey: string
sessionToken: string
}
```

## `ensureBlob()`

`ensureBlob()` is a handy util to validate a `Blob` by checking its size and type:
Expand Down Expand Up @@ -791,3 +853,103 @@ async function loadMore() {
</template>
```
::

### Create presigned URLs to upload files to R2

Presigned URLs can be used to upload files to R2 from client-side without using an API key.

:img{src="/images/docs/blob-presigned-urls.png" alt="NuxtHub presigned URLs to upload files to R2" width="915" height="515" class="rounded"}

As we use [aws4fetch](https://github.com/mhart/aws4fetch) to sign the request and [zod](https://github.com/colinhacks/zod) to validate the request, we need to install the packages:

```bash [Terminal]
npx nypm i aws4fetch zod
```

First, we need to create an API route that will return a presigned URL to the client.

```ts [server/api/blob/sign/\[...pathname\\].get.ts]
import { z } from 'zod'
import { AwsClient } from 'aws4fetch'

export default eventHandler(async (event) => {
const { pathname } = await getValidatedRouterParams(event, z.object({
pathname: z.string().min(1)
}).parse)
// Create credentials with the right permission & scope
const blob = hubBlob()
const { accountId, bucketName, ...credentials } = await blob.createCredentials({
permission: 'object-read-write',
pathnames: [pathname]
})
// Create the presigned URL
const client = new AwsClient(credentials)
const endpoint = new URL(
pathname,
`https://${bucketName}.${accountId}.r2.cloudflarestorage.com`
)
const { url } = await client.sign(endpoint, {
method: 'PUT',
aws: { signQuery: true }
})
// Return the presigned URL to the client
return { url }
})
```

::important
Make sure to authenticate the request on the server side to avoid letting anyone upload files to your R2 bucket. Checkout [nuxt-auth-utils](https://github.com/atinux/nuxt-auth-utils) as one of the possible solutions.
::

Next, we need to create the Vue page to upload a file to our R2 bucket using the presigned URL:

```vue [pages/upload.vue]
<script setup lang="ts">
async function uploadWithPresignedUrl(file: File) {
const { url } = await $fetch(`/api/blob/sign/${file.name}`)
await $fetch(url, {
method: 'PUT',
body: file
})
}
</script>
<template>
<input type="file" @change="uploadWithPresignedUrl($event.target.files[0])">
</template>
```

At this stage, you will get a CORS error because we did not setup the CORS on our R2 bucket.

To setup the CORS on our R2 bucket:
- Open the project on NuxtHub Admin with `npx nuxthub manage`
- Go to the **Blob tab** (make sure to be on the right environment: production or preview)
- Click on the Cloudflare icon on the top right corner
- Once on Cloudflare, Go to the `Settings` tab of your R2 bucket
- Scroll to **CORS policy**
- Click on `Edit CORS policy`
- Update the allowed origins with your origins by following this example:
```json
[
{
"AllowedOrigins": [
"http://localhost:3000",
"https://my-app.nuxt.dev"
],
"AllowedMethods": [
"GET",
"PUT"
],
"AllowedHeaders": [
"*"
]
}
]
```
- Save the changes

That's it! You can now upload files to R2 using the presigned URLs.

::callout
Read more about presigned URLs on Cloudflare's [official documentation](https://developers.cloudflare.com/r2/api/s3/presigned-urls/).
::
74 changes: 74 additions & 0 deletions docs/content/4.changelog/blob-presigned-urls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
title: Blob Presigned URLs
description: "It is now possible to upload files to your R2 bucket using presigned URLs with zero configuration."
date: 2024-10-15
image: '/images/docs/blob-presigned-urls.png'
authors:
- name: Sebastien Chopin
avatar:
src: https://avatars.githubusercontent.com/u/904724?v=4
to: https://x.com/atinux
username: atinux
---

It is now possible to upload files to your R2 bucket using presigned URLs with zero configuration.

::tip
This feature is available on all [NuxtHub plans](/pricing) and comes with the [v0.7.32](https://github.com/nuxt-hub/core/releases/tag/v0.7.32) release of `@nuxthub/core`.
::

## Why presigned URLs?

By allowing users to upload files to your R2 bucket using presigned URLs, you can:
- Reduce the server load
- Direct client-to-storage uploads, saving bandwidth and costs
- Use a secure and performant way to upload files to your R2 bucket
- By-pass the Workers limitation (100MiB max payload size) using regular upload

## How does it work?

This is a diagrame of the process of creating a presigned URL and uploading a file to R2:

:img{src="/images/docs/blob-presigned-urls.png" alt="NuxtHub presigned URLs to upload files to R2" width="915" height="515" class="rounded"}

In order to create the presigned URL, we created the [`hubBlob().createCredentials()`](/docs/features/blob#createcredentials) method.

This method will create temporary access credentials that can be optionally scoped to prefixes or objects in your bucket.

```ts
const {
accountId,
bucketName,
accessKeyId,
secretAccessKey,
sessionToken
} = await hubBlob().createCredentials({
permission: 'object-read-write',
pathnames: ['only-this-file.png']
})
```

With these credentials, you can now use the [aws4fetch](https://github.com/mhart/aws4fetch) library to create a presigned URL that can be used to upload a file to R2.

```ts
import { AwsClient } from 'aws4fetch'

// Create the presigned URL
const client = new AwsClient({ accessKeyId, secretAccessKey, sessionToken })
const endpoint = new URL(
'/only-this-file.png',
`https://${bucketName}.${accountId}.r2.cloudflarestorage.com`
)
const { url } = await client.sign(endpoint, {
method: 'PUT',
aws: { signQuery: true }
})
```

::callout{icon="i-ph-book-open-duotone" to="/docs/features/blob#create-presigned-urls-to-upload-files-to-r2"}
Checkout the example on how to create a presigned URL with NuxtHub.
::

## Alternative

If you don't want to use presigned URLs and want to upload files bigger than 100MiB, you can use the [NuxtHub Multipart Upload](/docs/features/blob#handlemultipartuploadd) feature.
Binary file added docs/public/images/docs/blob-presigned-urls.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 30 additions & 2 deletions playground/app/pages/blob.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
const loading = ref(false)
const loadingProgress = ref<number | undefined>(undefined)
const newFilesValue = ref<File[]>([])
const useSignedUrl = ref(false)
const uploadRef = ref<HTMLInputElement>()
const folded = ref(false)
const prefixes = ref<string[]>([])
const limit = ref(5)
const prefix = computed(() => prefixes.value?.[prefixes.value.length - 1])
const toast = useToast()
const { data: blobData } = await useFetch('/api/blob', {
const { data: blobData, refresh } = await useFetch('/api/blob', {
query: {
folded,
prefix,
Expand Down Expand Up @@ -45,6 +46,30 @@ async function addFile() {
}
loading.value = true
if (useSignedUrl.value) {
for (const file of newFilesValue.value) {
const url = await $fetch(`/api/blob/sign/${file.name}`, {
query: {
contentType: file.type,
contentLength: file.size
}
})
await $fetch(url, {
method: 'PUT',
body: file
})
.then(() => {
toast.add({ title: `File ${file.name} uploaded.` })
refresh()
})
.catch((err) => {
toast.add({ title: `Failed to upload ${file.name}.`, description: err.message, color: 'red' })
})
}
loading.value = false
return
}
try {
const uploadedFiles = await uploadFiles(newFilesValue.value)
files.value!.push(...uploadedFiles)
Expand Down Expand Up @@ -179,7 +204,10 @@ async function deleteFile(pathname: string) {
</UButtonGroup>
</div>
<UCheckbox v-model="folded" class="mt-2" label="View prefixes as directory" />
<div class="flex items-center gap-6 mt-2">
<UCheckbox v-model="folded" label="View prefixes as directory" />
<UCheckbox v-model="useSignedUrl" label="Use signed url to upload" />
</div>
<UProgress v-if="loading" :value="loadingProgress" :max="1" class="mt-2" />
Expand Down
4 changes: 3 additions & 1 deletion playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@
"@nuxthub/core": "latest",
"@nuxtjs/mdc": "^0.9.0",
"ai": "^3.4.7",
"aws4fetch": "^1.0.20",
"drizzle-orm": "^0.33.0",
"nuxt": "^3.13.2",
"postgres": "^3.4.4",
"puppeteer": "^23.5.0"
"puppeteer": "^23.5.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@nuxt/devtools": "latest"
Expand Down
23 changes: 23 additions & 0 deletions playground/server/api/blob/sign/[...pathname].get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { AwsClient } from 'aws4fetch'

/*
** Create a signed url to upload a file to R2
** https://developers.cloudflare.com/r2/api/s3/presigned-urls/#presigned-url-alternative-with-workers
*/
export default eventHandler(async (event) => {
const { pathname } = await getValidatedRouterParams(event, z.object({
pathname: z.string().min(1)
}).parse)
const { accountId, bucketName, ...credentials } = await hubBlob().createCredentials({
permission: 'object-read-write',
pathnames: [pathname]
})
const client = new AwsClient(credentials)
const endpoint = new URL(pathname, `https://${bucketName}.${accountId}.r2.cloudflarestorage.com`)

const { url } = await client.sign(endpoint, {
method: 'PUT',
aws: { signQuery: true }
})
return url
})
Loading

0 comments on commit 6502032

Please sign in to comment.