Signed Upload from Browser, with Backblaze B2

Signed Upload from Browser, with Backblaze B2

Uploading images from the browser to a server, then the server uploading that image to a hosting service can take a long time, as well as using unnecessary server data/bandwidth etc.

One way to stop all that is by directly uploading to the storage service from the browser (most services do provide this). "But then anyone could upload images to my bucket!" I hear you exclaim... Do not fear, for Signed Uploads are here.

In essence, the browser sends a request to your server, asking for authorization to upload a file. The server then processes this (checking for permissions etc), and asks the storage service for a special upload url and token. These are often one-time use, and expire after a certain amount of time. So the browser will never see any secret, only the signed url that the server sends back.

Yes, no doubt there are better explanations out there - just a Google away.

I like to use BackBlaze B2 for storage, as they are a lot cheaper than other services, and have a generous free tier. Luckily, they have an easy to work with REST API too. Onwards....

I will assume you have signed up for B2, created a bucket and account keys. You will also need to install the command-line client.

Edit Bucket

First, we need to adjust the buckets CORS rules, to allow to browsers from all origins. Unfortunately we can't add custom rules via the web interface, so we will need to do this via the command-line.

Once you have installed the B2 client, authorize your account with b2 authorize-account <account id> <account key>. Nice! Now copy the command below (replacing your bucket name):

b2 update-bucket --corsRules '[
    {
        "corsRuleName": "downloadFromAnyOriginWithUpload",
        "allowedOrigins": [
            "*"
        ],
        "allowedHeaders": [
            "*"
        ],
        "allowedOperations": [
            "b2_download_file_by_id",
            "b2_download_file_by_name",
            "b2_upload_file",
            "b2_upload_part"
        ],
        "exposeHeaders": [
            "authorization",
            "x-bz-file-name",
            "x-bz-content-sha1"
        ],
        "maxAgeSeconds": 3600
    }
]' <bucket-name> allPublic

Quick explanation: We are simply updating the CORS rules to allow access from all origins, and allowing upload operations. We also specify allPublic so we can use signed uploads. More here: B2 CORS

Bear in mind these changes could take up to 10 minutes to take effect.

Now we get to the code part. I am writing this in NodeJS, but as we are using the REST API, you could use equivalent libraries/commands in any language.

Generate Upload URL

Let's import axios (my preferred client, feel free to choose your own), and set some env's.

const axios = require( 'axios' )

const b2_id = 'account/app id'
const b2_key = '<account/app key>'
const b2_bucket_id = '<bucket id>' # Can be found in the dashboard, under Buckets > Bucket Id

Now we can create a function to send our keys to B2, and get an authorization token - which we can then use to perform operations against the API.

According the Backblaze docs:

  • The application key id and application key are combined into a string in the format "applicationKeyId:applicationKey".
  • The combined string is Base64 encoded.
  • "Basic " is put before the encoded string.

So we combine the two keys, and Base64 encode them. We then add this as a header, and send the request to the API.

const authorizeAccount = async () => {
  try {
    const keys = Buffer.from(`${b2_id}:${b2_key}`).toString('base64')
    const response = await axios.get(`https://api.backblazeb2.com/b2api/v2/b2_authorize_account`, { headers: {Authorization: `Basic ${keys}`} })
    return response.data
  } catch (error) {
    throw new Error(error.message)
  }
}

The request above will return data like the below - with these we can identify what API url to use, and we have our authorization token.

{
  "absoluteMinimumPartSize": 5000000,
  "accountId": "YOUR_ACCOUNT_ID",
  "allowed": {
    "bucketId": "BUCKET_ID",
    "bucketName": "BUCKET_NAME",
    "capabilities": [
      "listBuckets",
      "listFiles",
      "readFiles",
      "shareFiles",
      "writeFiles",
      "deleteFiles"
    ],
    "namePrefix": null
  },
  "apiUrl": "https://apiNNN.backblazeb2.com",
  "authorizationToken": "4_0022623512fc8f80000000001_0186e431_d18d02_acct_tH7VW03boebOXayIc43-sxptpfA=",
  "downloadUrl": "https://f002.backblazeb2.com",
  "recommendedPartSize": 100000000
}

Now we can create another function to get the actual upload url. We use the url from the above function, and attach the authorization token.

const getUploadUrl = async ( { apiUrl, authorizationToken } ) => {
  try {
    const response = await axios.post(`${apiUrl}/b2api/v2/b2_get_upload_url`, { bucketId: b2_bucket_id }, { headers: { Authorization: authorizationToken }})
    return response.data
  } catch (error) {
    throw new Error(error.message)
  }
}

This will result in a response like the below - note our uploadUrl, and authorizationToken.

{ 
    authorizationToken:
   '4_1568749g5488e2a0000000001_018dec82_777d25_upld_inhTbpA6kddKcRgUddfRff0L-eqE=',
  bucketId: '31678152489h49a968ce052g',
  uploadUrl:
   'https://pod-000-1125-11.backblaze.com/b2api/v2/b2_upload_file/31675588437d49a578ce021a/c002_v0001125_t0024' 
}

Now we can put these together into an upload function. You can implement responses according to the server you are using - You will just need to return JSON data.

async function uploadRequest() {
  try {
    const authorization = await authorizeAccount()

    const uploadUrl = await getUploadUrl( authorization )
    return uploadUrl
  } catch (error) {
    return error
  }
}

So our browser client should send a request to uploadRequest, and should get a JSON response with at least these two fields: uploadUrl, and authorizationToken.

Browser Upload

Now we can use Axios on the front end to upload a file. First we send a request to get the upload url and token,then we send a file to the url.

You will note there are a few headers we have to include, as per the docs.

Again, this can be used with any client and framework etc.

<!DOCTYPE html>
<html lang="en">

  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Upload</title>
  </head>

  <body>
    <section>
      <form action="">
        <label for="avatar">Choose an image:</label>
        <input type="file" id="image" name="image" accept="image/png, image/jpeg" oninput="upload(this.files)">
                <p>Progress: <span id="totalProgress"></span></p>
      </form>
    </section>
    <script>
      async function upload(files) {
        const [ image ] = files
        const { data: { uploadUrl, authorizationtoken } } = await axios.get('http://path-to-server.com/uploadRequest')

        const response = await axios.post( uploadUrl, image, {
          headers: {
            Authorization: authorizationToken,
            'Content-Type': 'b2/x-auto',
            'X-Bz-File-Name': `some-folder/${image.filename}`,
            'X-Bz-Content-Sha1': 'do_not_verify' // Yes, you probably should.
          },
          onUploadProgress: ({ loaded, total }) => {
            const totalProgressSpan = document.getElementById('totalProgress')
            const totalProgress = parseInt((loaded / total) * 100)
            console.log(`${totalProgress}%`)
            totalProgressSpan.innerText = `${totalProgress}%`
          }
        } )
        console.log( response )
      }
    </script>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
  </body>

</html>

Now browse to your bucket, and you should see some files present... excellent work!

Have fun.

Address

Address?
Not happening.
I get enough email spam as it is ¯\_(ツ)_/¯