Presigned URLs
Presigned URLs via edge scripting for bunny.net 🐰
Introduction
With the addition of edge scripting, it is now possible to intercept requests and responses to and from bunny.net pull zones.
In this guide, we will write our own standalone edge script to sign urls and upload files using the bunny-presigned-urls package.
Why standalone scripts?
Currently, there is a bug with middleware scripts that blocks all POST requests when the middleware script is connected to a pull zone that has a storage zone as its origin. This bug is a nonstarter for middleware scripts.
In addition, using a standalone script minimizes latency on your pull zone and storage zone.
Authentication
While presigned urls can be authenticated with their signatures, you still need to authenticate users who create those presigned urls.
This guide authenticates users with a custom AccessKey, which is a string value you set in the environment variables. The request AccessKey header value is compared to the environment variable for authentication.
Depending on your app, you may decide to implement cookie or token authentication.
Those implementation details are left to you so that you can use your existing authentication implementation or the authentication implementation of your choice.
If you are using token authentication, please check out the jose (JSON Object Signing and Encryption) package.
Limitations
Platform limitations
The main limitations are from the edge scripting platform:
- CPU Time per request is limited to
30s - Active memory is limited to
128MB
While the bunny-presigned-urls package uses ReadableStreams to minimize memory usage, the maximum file upload size is still subject to the amount of time it takes for the script to upload it to your storage zone. This time is variable, depending on the storage zone region, storage zone type, and edge script location. Local upload testing from the United States is confirmed to work with files up to 1GB. Your milage may vary.
Nonetheless, this example with bunny-presigned-urls is well-suited for image uploads and other text-based files.
If you are dealing with videos, you should be using Bunny Stream and their presigned urls with the TUS Resumable Uploads endpoint instead.
Default package limits
In addition to the main limitations from bunny.net, the bunny-presigned-urls package introduces common-sense, default limits too.
By default, the package:
- limits the maximum upload size (
maxSize) to10MB - limits the upload time (
expires) to1hr - requires a
checksumto validate the exact file uploaded
Remove these limits by setting:
maxSizetoInfinityexpiresto1000yrchecksumtofalse
Please note:
- You must set the custom
maxSizeandexpiresoptions in both thesignUrlanduploadFilefunctions - When
maxSizeis less thanInfinity, you must pass thefileSizeInBytesoption to thesignUrlfunction - You must set the custom
checksumoption in thesignUrlfunction
Preparing the edge script
Creating the script
Follow bunny.net’s quickstart guide to create a standalone script.
Currently, there is a bug with edge scripts that breaks all logging. Check your browser console for this error to confirm it:
WebSocket connection to '<URL>' failed: WebSocket is closed before the connection is established.
Setting environment variables
Navigate to the Environment variables tab and set these environment variables:
- Set
KEYto a random 32-character hex string - Set
ACCESS_KEYto any secret string - Set
STORAGE_ZONE_NAMEto your storage zone name - Set
STORAGE_ZONE_PASSWORDto your storage zone password - Set
STORAGE_ZONE_STORAGE_HOSTNAMEto your storage zone hostname
For your KEY and ACCESS_KEY values, feel free to generate them with npx --yes bunny-presigned-urls@latest
To find the storage zone values, visit:
- The new Dashboard > Storage > Storage Zone Name > FTP & API Access
- The old Panel > Storage > Storage Zone Name > FTP & API Access
Writing the edge script
Adding imports
Start by importing the following packages. Because the runtime is based on a modified Deno runtime, URL imports are permitted.
import * as BunnySDK from 'https://esm.sh/@bunny.net/edgescript-sdk@0.11.2'import { signUrl, uploadFile,} from 'https://cdn.jsdelivr.net/npm/bunny-presigned-urls@0.0.5/dist/index.js'import * as process from 'node:process'import { z } from 'https://esm.run/zod@3.23.8'
Configuration
Parse your env values and set other config values:
const maxSize = '10MB'const expires = '1hr'const signPathname = '/sign'const uploadPathname = '/upload'const configSchema = z.object({ accessKey: z.string(), key: z.string(), storageZone: z.object({ name: z.string(), password: z.string(), storageHostname: z.string(), }),})type Config = z.infer<typeof configSchema>function readConfigFromEnv(): Config { const config = configSchema.parse({ accessKey: process.env.ACCESS_KEY, key: process.env.KEY, storageZone: { name: process.env.STORAGE_ZONE_NAME, password: process.env.STORAGE_ZONE_PASSWORD, storageHostname: process.env.STORAGE_ZONE_STORAGE_HOSTNAME, }, }) return config}const config = readConfigFromEnv()// validate user inputsconst parametersSchema = z.object({ checksum: z.string().length(64), filePath: z.string(), fileSizeInBytes: z.number().int().positive(),})
For the filePath, consider:
- validating it matches a pattern
/images/* - generating it from the checksum
Create the serve function handler
Prepare the default routes and responses:
BunnySDK.net.http.serve(async (request: Request): Promise<Response> => { try { // workaround bug where request.url protocol is http://, not https:// const requestUrl = request.url.replace("http://", "https://"); const url = new URL(requestUrl); // sign // upload // fallback for all other routes return new Response(undefined, { status: 404, statusText: "Not Found", }); } catch { // hide 500 errors for security return new Response(undefined, { status: 500, statusText: "Internal Server Error", }); }
Configure signed urls
// signif (request.method === 'POST' && url.pathname === signPathname) { // authorize user if (request.headers.get('AccessKey') !== config.accessKey) { return new Response(undefined, { status: 401, statusText: 'Unauthorized', }) } // validate inputs const parameters = parametersSchema.safeParse(await request.json()) if (!parameters.success) { return new Response('Invalid parameters', { status: 400, statusText: 'Bad Request', }) } // return signed url response return await signUrl({ baseUrl: url.origin + uploadPathname, checksum: parameters.data.checksum, expires, filePath: parameters.data.filePath, fileSizeInBytes: parameters.data.fileSizeInBytes, key: config.key, maxSize, storageZone: config.storageZone, })}
Configure file uploads
// uploadif (request.method === 'POST' && url.pathname === uploadPathname) { // optionally, validate the file type before upload, but be aware of gotchas // return uploaded file response return await uploadFile({ body: request.body, expires, key: config.key, maxSize, storageZone: config.storageZone, url: requestUrl, })}
If you choose to validate file uploads, please be aware that:
- Converting the
request.bodyfrom aReadableStreamto anUint8Arrayviaawait request.bytes();may cause your edge script to run out of its128MBof memory - Splitting the
request.bodyinto two streams viarequest.body.tee()will signal backpressure at the rate of the faster consumer, meaning unread data is enqueued internally by the slower consumer without any limit or backpressure
File size and file checksum validation is handled internally by the package.
Writing the upload script
Browser
In the browser, you will likely receive a File object from a file input, the File System API, or OPFS.
To get a ReadableStream from the File object:
file.stream()
To get the fileSizeInBytes from the File object:
file.size
Node.js
In Node.js, there are many different ways to access files and their stats.
To get a ReadableStream:
import { createReadStream } from 'node:fs'import path from 'node:path'import { Readable } from 'node:stream'Readable.toWeb( createReadStream(path.resolve(filePath)),) as ReadableStream<Uint8Array>
To get the fileSizeInBytes:
import { stat } from 'node:fs/promises'import path from 'node:path'const stats = await stat(path.resolve(filePath))stats.size
Example
This example is for the Browser. For Node.js, swap out the ReadableStream and fileSizeInBytes for their equivalents above.
This example uses top-level await. If your runtime does not support top-level await, you will need to wrap the code in an async function.
Regardless of the Browser or Node.js environment, streams can only be read from once.
import { checksumFromReadableStream } from 'bunny-presigned-urls'// retrieve the filelet file: File// this example authenticates with the AccessKey header// however, you will likely use token or cookie authentication insteadconst AccessKey = 'd377c532a8f1ebe57e15983d8902f5c4'// copy the url for the edge scriptconst baseUrl = 'https://my-presigned-urls-standalone-ltz3z.b-cdn.net'const signedUrlResponse = await fetch(`${baseUrl}/sign`, { body: JSON.stringify({ checksum: await checksumFromReadableStream(file.stream()), filePath: `/images/${file.name}`, fileSizeInBytes: file.bytes(), }), headers: { AccessKey, Referer: `${baseUrl}/`, }, method: 'POST',})const message = await signedUrlResponse.text()if (URL.canParse(message)) { const uploadResponse = await fetch(message, { body: file.stream(), duplex: 'half', headers: { Referer: `${baseUrl}/`, }, method: 'POST', }) const { status, statusText } = uploadResponse if (status === 201) { console.log('Done') } else { console.error({ message: await uploadResponse.text(), status, statusText, }) }} else { const { status, statusText } = signedUrlResponse console.error({ message, status, statusText, })}