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 +}