diff --git a/docs/content/0.index.yml b/docs/content/0.index.yml
index 5dfeab26..68014038 100644
--- a/docs/content/0.index.yml
+++ b/docs/content/0.index.yml
@@ -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
diff --git a/docs/content/1.docs/2.features/blob.md b/docs/content/1.docs/2.features/blob.md
index 6ac3f2e2..a6377cf8 100644
--- a/docs/content/1.docs/2.features/blob.md
+++ b/docs/content/1.docs/2.features/blob.md
@@ -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:
@@ -791,3 +853,103 @@ async function loadMore() {
```
::
+
+### 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]
+
+
+
+
+
+```
+
+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/).
+::
diff --git a/docs/content/4.changelog/blob-presigned-urls.md b/docs/content/4.changelog/blob-presigned-urls.md
new file mode 100644
index 00000000..a64ce86c
--- /dev/null
+++ b/docs/content/4.changelog/blob-presigned-urls.md
@@ -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.
diff --git a/docs/public/images/docs/blob-presigned-urls.png b/docs/public/images/docs/blob-presigned-urls.png
new file mode 100644
index 00000000..d3f58c7f
Binary files /dev/null and b/docs/public/images/docs/blob-presigned-urls.png differ
diff --git a/playground/app/pages/blob.vue b/playground/app/pages/blob.vue
index 0f281072..acc936e9 100644
--- a/playground/app/pages/blob.vue
+++ b/playground/app/pages/blob.vue
@@ -2,6 +2,7 @@
const loading = ref(false)
const loadingProgress = ref(undefined)
const newFilesValue = ref([])
+const useSignedUrl = ref(false)
const uploadRef = ref()
const folded = ref(false)
const prefixes = ref([])
@@ -9,7 +10,7 @@ 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,
@@ -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)
@@ -179,7 +204,10 @@ async function deleteFile(pathname: string) {
-
+
+
+
+
diff --git a/playground/package.json b/playground/package.json
index 7cba31cb..d27c5a79 100644
--- a/playground/package.json
+++ b/playground/package.json
@@ -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"
diff --git a/playground/server/api/blob/sign/[...pathname].get.ts b/playground/server/api/blob/sign/[...pathname].get.ts
new file mode 100644
index 00000000..75f73d31
--- /dev/null
+++ b/playground/server/api/blob/sign/[...pathname].get.ts
@@ -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
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a32ed39c..c88109b1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -194,13 +194,16 @@ importers:
version: 2.18.6(focus-trap@7.6.0)(magicast@0.3.5)(rollup@4.21.3)(vite@5.4.5(@types/node@22.7.5)(terser@5.34.1))(vue@3.5.6(typescript@5.6.3))(webpack-sources@3.2.3)
'@nuxthub/core':
specifier: latest
- version: 0.7.30(ioredis@5.4.1)(magicast@0.3.5)(rollup@4.21.3)(vite@5.4.5(@types/node@22.7.5)(terser@5.34.1))(webpack-sources@3.2.3)
+ version: 0.7.31(ioredis@5.4.1)(magicast@0.3.5)(rollup@4.21.3)(vite@5.4.5(@types/node@22.7.5)(terser@5.34.1))(webpack-sources@3.2.3)
'@nuxtjs/mdc':
specifier: ^0.9.0
version: 0.9.0(magicast@0.3.5)(rollup@4.21.3)(webpack-sources@3.2.3)
ai:
specifier: ^3.4.7
version: 3.4.7(react@18.3.1)(sswr@2.1.0(svelte@4.2.19))(svelte@4.2.19)(vue@3.5.6(typescript@5.6.3))(zod@3.23.8)
+ aws4fetch:
+ specifier: ^1.0.20
+ version: 1.0.20
drizzle-orm:
specifier: ^0.33.0
version: 0.33.0(@cloudflare/workers-types@4.20241011.0)(@opentelemetry/api@1.9.0)(postgres@3.4.4)(react@18.3.1)
@@ -213,6 +216,9 @@ importers:
puppeteer:
specifier: ^23.5.0
version: 23.5.0(typescript@5.6.3)
+ zod:
+ specifier: ^3.23.8
+ version: 3.23.8
devDependencies:
'@nuxt/devtools':
specifier: latest
@@ -1851,8 +1857,8 @@ packages:
'@nuxthq/studio@2.1.1':
resolution: {integrity: sha512-NQMf1Howrr5D7fDRMSpYyjQSi3/RzUT91KfcLxGz3Q2iAq0y94GSlPCpYMqYId9CgcfG2OIIDm40/dFusQZIvQ==}
- '@nuxthub/core@0.7.30':
- resolution: {integrity: sha512-7DCDFmxmIBMijZTX0LOZZ3PNj8pIXxJ0HaOrMaTyh6TXSOGqRKt1zsM/k4baUhMyNKem+2dLv7326g2YieG0FA==}
+ '@nuxthub/core@0.7.31':
+ resolution: {integrity: sha512-2pCjk+QPtYIrO56/bi2zgoW5/12Q0m1nNqoPBOLzbthC07HTqIcnKe2wetwlpD6YKDJZDKyZgm8ypDbkUyBI/A==}
'@nuxtjs/color-mode@3.5.1':
resolution: {integrity: sha512-GRHF3WUwX6fXIiRVlngNq1nVDwrVuP6dWX1DRmox3QolzX0eH1oJEcFr/lAm1nkT71JVGb8mszho9w+yHJbePw==}
@@ -3160,6 +3166,9 @@ packages:
peerDependencies:
postcss: ^8.1.0
+ aws4fetch@1.0.20:
+ resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==}
+
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
@@ -9771,7 +9780,7 @@ snapshots:
- utf-8-validate
- webpack-sources
- '@nuxthub/core@0.7.30(ioredis@5.4.1)(magicast@0.3.5)(rollup@4.21.3)(vite@5.4.5(@types/node@22.7.5)(terser@5.34.1))(webpack-sources@3.2.3)':
+ '@nuxthub/core@0.7.31(ioredis@5.4.1)(magicast@0.3.5)(rollup@4.21.3)(vite@5.4.5(@types/node@22.7.5)(terser@5.34.1))(webpack-sources@3.2.3)':
dependencies:
'@cloudflare/workers-types': 4.20241011.0
'@nuxt/devtools-kit': 1.6.0(magicast@0.3.5)(rollup@4.21.3)(vite@5.4.5(@types/node@22.7.5)(terser@5.34.1))(webpack-sources@3.2.3)
@@ -9787,6 +9796,7 @@ snapshots:
ofetch: 1.4.1
pathe: 1.1.2
pkg-types: 1.2.1
+ std-env: 3.7.0
ufo: 1.5.4
uncrypto: 0.1.3
unstorage: 1.12.0(ioredis@5.4.1)
@@ -11658,6 +11668,8 @@ snapshots:
postcss: 8.4.47
postcss-value-parser: 4.2.0
+ aws4fetch@1.0.20: {}
+
axobject-query@4.1.0: {}
b4a@1.6.7: {}
diff --git a/src/runtime/blob/server/api/_hub/blob/credentials.post.ts b/src/runtime/blob/server/api/_hub/blob/credentials.post.ts
new file mode 100644
index 00000000..0fca0dc6
--- /dev/null
+++ b/src/runtime/blob/server/api/_hub/blob/credentials.post.ts
@@ -0,0 +1,12 @@
+import { eventHandler, readBody } from 'h3'
+import { hubBlob } from '../../../utils/blob'
+import { requireNuxtHubAuthorization } from '../../../../../utils/auth'
+import { requireNuxtHubFeature } from '../../../../../utils/features'
+
+export default eventHandler(async (event) => {
+ await requireNuxtHubAuthorization(event)
+ requireNuxtHubFeature('blob')
+
+ const options = await readBody(event)
+ return hubBlob().createCredentials(options)
+})
diff --git a/src/runtime/blob/server/utils/blob.ts b/src/runtime/blob/server/utils/blob.ts
index 939ef495..b3342c49 100644
--- a/src/runtime/blob/server/utils/blob.ts
+++ b/src/runtime/blob/server/utils/blob.ts
@@ -8,7 +8,7 @@ import { defu } from 'defu'
import { randomUUID } from 'uncrypto'
import { parse } from 'pathe'
import { joinURL } from 'ufo'
-import type { BlobType, FileSizeUnit, BlobUploadedPart, BlobListResult, BlobMultipartUpload, HandleMPUResponse, BlobMultipartOptions, BlobUploadOptions, BlobPutOptions, BlobEnsureOptions, BlobObject, BlobListOptions } from '@nuxthub/core'
+import type { BlobType, FileSizeUnit, BlobUploadedPart, BlobListResult, BlobMultipartUpload, HandleMPUResponse, BlobMultipartOptions, BlobUploadOptions, BlobPutOptions, BlobEnsureOptions, BlobObject, BlobListOptions, BlobCredentialsOptions, BlobCredentials } from '@nuxthub/core'
import { streamToArrayBuffer } from '../../../utils/stream'
import { requireNuxtHubFeature } from '../../../utils/features'
import { useRuntimeConfig } from '#imports'
@@ -136,6 +136,18 @@ interface HubBlob {
* @see https://hub.nuxt.com/docs/features/blob#handleupload
*/
handleUpload(event: H3Event, options?: BlobUploadOptions): Promise
+ /**
+ * Creates temporary access credentials that can be optionally scoped to prefixes or objects.
+ *
+ * Useful to create a signed url to upload directory to R2 from client-side.
+ *
+ * Only available in production or in development with `--remote` flag.
+ *
+ * @example ```ts
+ * const { accountId, bucketName, accessKeyId, secretAccessKey, sessionToken } = await hubBlob().createCredentials()
+ * ```
+ */
+ createCredentials(options?: BlobCredentialsOptions): Promise
}
/**
@@ -306,6 +318,23 @@ export function hubBlob(): HubBlob {
}
return objects
+ },
+ async createCredentials(options: BlobCredentialsOptions = {}): Promise {
+ if (import.meta.dev) {
+ throw createError('hubBlob().createCredentials() is only available in production or in development with `--remote` flag.')
+ }
+ if (!process.env.NUXT_HUB_PROJECT_DEPLOY_TOKEN) {
+ throw createError('Missing `NUXT_HUB_PROJECT_DEPLOY_TOKEN` environment variable, make sure to deploy with `npx nuxthub deploy` or with the NuxtHub Admin.')
+ }
+ const env = process.env.NUXT_HUB_ENV || hub.env || 'production'
+ return await $fetch(`/api/projects/${hub.projectKey}/blob/${env}/credentials`, {
+ baseURL: hub.url,
+ method: 'POST',
+ body: options,
+ headers: {
+ authorization: `Bearer ${process.env.NUXT_HUB_PROJECT_DEPLOY_TOKEN}`
+ }
+ })
}
}
return {
@@ -442,6 +471,13 @@ export function proxyHubBlob(projectUrl: string, secretKey?: string): HubBlob {
body: await readFormData(event),
query: options
})
+ },
+
+ async createCredentials(options: BlobCredentialsOptions = {}): Promise {
+ return await blobAPI('/credentials', {
+ method: 'POST',
+ body: options
+ })
}
}
diff --git a/src/types/blob.ts b/src/types/blob.ts
index 1a150667..5b07c2e0 100644
--- a/src/types/blob.ts
+++ b/src/types/blob.ts
@@ -210,3 +210,47 @@ export interface BlobListResult {
*/
folders?: string[]
}
+
+export interface BlobCredentialsOptions {
+ /**
+ * The permission of the credentials.
+ * @default 'admin-read-write'
+ */
+ permission?: 'admin-read-write' | 'admin-read-only' | 'object-read-write' | 'object-read-only'
+ /**
+ * The ttl of the credentials in seconds.
+ * @default 900
+ */
+ ttl?: number
+ /**
+ * The prefixes to scope the credentials to.
+ */
+ prefixes?: string[]
+ /**
+ * The pathnames to scope the credentials to.
+ */
+ pathnames?: string[]
+}
+
+export interface BlobCredentials {
+ /**
+ * The Cloudflare account id
+ */
+ accountId: string
+ /**
+ * The Cloudflare R2 bucket name
+ */
+ bucketName: string
+ /**
+ * The access key id for the R2 bucket
+ */
+ accessKeyId: string
+ /**
+ * The secret access key for the R2 bucket
+ */
+ secretAccessKey: string
+ /**
+ * The session token for the R2 bucket
+ */
+ sessionToken: string
+}