maz

maz

Backblaze and Phoenix LiveView Uploads

Has anyone uploaded to backblaze with Phoenix LiveView Upload? I think I am very close with the upload(it seems to upload the entire file but at the last moment I see this error):

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at https://s3.us-east-005.backblazeb2.com/the-bucket-name. (Reason: CORS header ‘Access-Control-Allow-Origin’ missing). Status code: 501.

I’m pretty sure my CORS bucket policy is extremely liberal and I do not see why I’m getting the allow origin issue:

these are the CORS rules on the bucket:

    "corsRules": [
        {
            "allowedHeaders": [
                "*"
            ],
            "allowedOperations": [
                "s3_head",
                "b2_download_file_by_id",
                "b2_upload_part",
                "b2_upload_file",
                "s3_put",
                "b2_download_file_by_name",
                "s3_post",
                "s3_get"
            ],
            "allowedOrigins": [
                "*"
            ],
            "corsRuleName": "downloadFromAnyOriginWithUpload",
            "exposeHeaders": [
                "x-bz-content-sha1"
            ],
            "maxAgeSeconds": 3600
        }
    ]

and here is how I presign:

  def presign_upload(entry, socket) do
    uploads = socket.assigns.uploads
    bucket = System.get_env("BACKBLAZE_S3_BUCKET_ID")
    key = Ecto.UUID.generate() <> Path.extname(entry.client_name)
    config = %{
      region: System.get_env("BACKBLAZE_S3_REGION"),
      access_key_id: System.get_env("BACKBLAZE_S3_APPLICATION_KEY_ID"),
      secret_access_key: System.get_env("BACKBLAZE_S3_APPLICATION_KEY")
    }

    {:ok, fields} =
      sign_form_upload(config, bucket,
        key: key,
        content_type: entry.client_type,
        max_file_size: uploads[entry.upload_config].max_file_size,
        expires_in: :timer.hours(1)
      )

    dbg(fields)
    dbg(key)

    meta = %{
      uploader: "S3",
      key: key,
      url: "https://s3.us-east-005.backblazeb2.com/#{System.get_env("BACKBLAZE_S3_BUCKET_ID")}",

      fields: fields
    }

    dbg(meta)

    {:ok, meta, socket}
  end

The presign data looks like this:

[(word_app 1.5.1) lib/word_app/file_uploads/s3_backblaze.ex:124: WordApp.FileUploads.S3Backblaze.presign_upload/2]
key #=> "ebd306d1-ca78-4bf6-95a6-44e0bb1808ac.jpg"

[(word_app 1.5.1) lib/word_app/file_uploads/s3_backblaze.ex:137: WordApp.FileUploads.S3Backblaze.presign_upload/2]
meta #=> %{
  fields: %{
    "acl" => "public-read",
    "content-type" => "image/jpeg",
    "key" => "ebd306d1-ca78-4bf6-95a6-44e0bb1808ac.jpg",
    "policy" => "ewogICJleHBpcmF0aW9uIjogIjIwMjMtMDctMjBUMDU6NTY6MjYuNTk4Njg1WiIsCiAgImNvbmRpdGlvbnMiOiBbCiAgICB7ImJ1Y2tldCI6ICAiZ29zaGVuLW1lZGlhLW1haW4ifSwKICAgIFsiZXEiLCAiJGtleSIsICJlYmQzMDZkMS1jYTc4LTRiZjYtOTVhNi00NGUwYmIxODA4YWMuanBnIl0sCiAgICB7ImFjbCI6ICJwdWJsaWMtcmVhZCJ9LAogICAgWyJlcSIsICIkQ29udGVudC1UeXBlIiwgImltYWdlL2pwZWciXSwKICAgIFsiY29udGVudC1sZW5ndGgtcmFuZ2UiLCAwLCA4MDAwMDAwXSwKICAgIHsieC1hbXotc2VydmVyLXNpZGUtZW5jcnlwdGlvbiI6ICJBRVMyNTYifSwKICAgIHsieC1hbXotY3JlZGVudGlhbCI6ICIwMDU1MDgxYzYzOWQxYmYwMDAwMDAwMDAxLzIwMjMwNzIwL3VzLWVhc3QtMDA1L3MzL2F3czRfcmVxdWVzdCJ9LAogICAgeyJ4LWFtei1hbGdvcml0aG0iOiAiQVdTNC1ITUFDLVNIQTI1NiJ9LAogICAgeyJ4LWFtei1kYXRlIjogIjIwMjMwNzIwVDA1NTYyNloifQogIF0KfQo=",
    "x-amz-algorithm" => "AWS4-HMAC-SHA256",
    "x-amz-credential" => "0055081c639d1bf0000000001/20230720/us-east-005/s3/aws4_request",
    "x-amz-date" => "20230720T055626Z",
    "x-amz-server-side-encryption" => "AES256",
    "x-amz-signature" => "1d16e41aef14551b9efed9aab874fa18e5e326962dcf93d6e4cdbc8cd5952182"
  },
  key: "ebd306d1-ca78-4bf6-95a6-44e0bb1808ac.jpg",
  url: "https://s3.us-east-005.backblazeb2.com/the-bucket-name",
  uploader: "S3"
}

Marked As Solved

emadalam

emadalam

Here’s a minimal but complete working example for you.

# Utility module to deal with S3 operations
defmodule MyAppWeb.S3 do
  def presigned_put(opts) do
    key = Keyword.fetch!(opts, :key)
    max_file_size = opts[:max_file_size] || 10_000_000
    expires_in = opts[:expires_in] || 7200

    uri = "https://s3.us-east-005.backblazeb2.com/bucket-name/#{key}"

    url =
      :aws_signature.sign_v4_query_params(
        "secret",
        "secret2",
        "us-east-005",
        "S3",
        :calendar.universal_time(),
        "PUT",
        uri,
        ttl: expires_in,
        uri_encode_path: false,
        body_digest: "UNSIGNED-PAYLOAD"
      )

    {:ok, url}
  end
end
# Phoenix live upload
defmodule MyAppWeb.UploadLive do
  use MyAppWeb, :live_view

  @impl Phoenix.LiveView
  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(:uploaded_files, [])
     |> allow_upload(:avatar,
       max_file_size: 50_000_000,
       accept: ~w(.jpg .jpeg .png .gif .svg .webp),
       max_entries: 1,
       external: &presign_upload/2
     )}
  end

  defp presign_upload(entry, socket) do
    uploads = socket.assigns.uploads
    key = "public/#{URI.encode(entry.client_name)}"

    {:ok, presigned_url} = MyAppWeb.S3.presigned_put(key: key)

    meta = %{uploader: "S3", key: key, url: presigned_url}
    {:ok, meta, socket}
  end

end
// assets/js/app.js
...
...

const Uploaders = {}

Uploaders.S3 = function(entries, onViewError){
  entries.forEach(entry => {
    let {url} = entry.meta
    let xhr = new XMLHttpRequest()

    onViewError(() => xhr.abort())
    xhr.onload = () => {
      xhr.status >= 200 && xhr.status < 300 ? entry.progress(100) : entry.error()
    }
    xhr.onerror = () => entry.error()
    xhr.upload.addEventListener("progress", (event) => {
      if(event.lengthComputable){
        let percent = Math.round((event.loaded / event.total) * 100)
        if(percent < 100){ entry.progress(percent) }
      }
    })

    xhr.open("PUT", url, true)
    xhr.send(entry.file)
  })
}
...
...
let liveSocket = new LiveSocket("/live", Socket, {
  uploaders: Uploaders,
  params: {_csrf_token: csrfToken}
})
...

Also Liked

metadaddy

metadaddy

Hi there - I don’t have any experience with Phoenix LiveView in particular, but, as Chief Technical Evangelist at Backblaze, I have worked with Backblaze B2 quite a bit! I’ll work through some of the points in this thread in the hope that it moves you a bit closer to getting presigned URLs working.

@thomas.fortes is correct in his advice to use PUT rather than POST - B2 does not support POST for the S3 PutObject operation.

@maz It’s revealing that the error code changed with the switch from POST to PUT. When you tried to use POST, you received a 501, “not implemented”. If you were to look at the payload in the response, it would be

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Error>
    <Code>NotImplemented</Code>
    <Message>This API call is not supported.</Message>
</Error>

With POST, you are receiving 403, “forbidden”, which indicates that something is wrong in either the presigned URL, or the key you are using to generate it. Debugging tip - the response payload will give you more detail on what the problem actually is.

CORS is a bit of a red herring here. The browser complains about the missing Access-Control-Allow-Origin header, but it’s never going to see one on a 501 or 403 error response. Start worrying about CORS when you see 20x responses.

Moving along to the code… The bad news is that sign_form_upload (code) does not actually presign a URL. It submits a POST request with the signature and other parameters sent as form fields (AWS doc), which, as mentioned earlier, is not supported by B2. Unfortunately, changing POST to PUT won’t make it work, as the payload will still be a form submission. B2 responds with 403 since it can’t see the signature it’s expecting as either an HTTP header or a query parameter.

In a presigned URL, the signature and other params are query parameters on the URL (AWS doc). Here’s a real presigned, but expired, URL for uploading the file HelloWorld.txt to my metadaddy-private B2 bucket, with line breaks added so you can see the query parameters clearly:

https://s3.us-west-004.backblazeb2.com/metadaddy-private/HelloWorld.txt?
X-Amz-Algorithm=AWS4-HMAC-SHA256&
X-Amz-Credential=00415f935cf4dcb0000000046%2F20230720%2Fus-west-004%2Fs3%2Faws4_request&
X-Amz-Date=20230720T230347Z&
X-Amz-Expires=60&
X-Amz-SignedHeaders=host&
X-Amz-Signature=5b9d9762a8aec0eedc54abacabdd22c8cb9c120e3811f331e2821dc6b2217915

Another debugging tip - if you can capture the presigned URL, you can test it at the command line with curl, like this:

% curl -i -X PUT --data-binary @HelloWorld.txt 'https://s3.us-west-004.backblazeb2.com/metadaddy-private/...'
HTTP/1.1 200 
x-amz-request-id: 05d9e319b2960ffb
x-amz-id-2: aMZc1tmblObEzMzXXY0pm/zRVZBtjHWIJ
ETag: "59ca0efa9f5633cb0371bbc0355478d8"
x-amz-version-id: 4_z0145cfc9e3f5ec0f74ed0c1b_f409ee060e487b08b_d20230720_m234423_c004_v0402011_t0013_u01689896663895
Cache-Control: max-age=0, no-cache, no-store
Content-Length: 0
Date: Thu, 20 Jul 2023 23:44:23 GMT

The good news… In researching this, I came upon a comment on the sign_form_upload gist pointing to a dependency-free implementation of presigned URLs. I don’t have the ability to test this, but it looks like it does the right things. Even better, it looks like the author of that code, @denvaar, has an account here and might be able to help, too.

Hope this helps - good luck getting it working!

16
Post #8
emadalam

emadalam

I’ve been tinkering with this myself for a couple of days. I was able to create the presigned url that allowed me to upload to Backblaze directly, however for some reason the uploaded file was corrupted all the time when I tried accessing the file even when the file size and everything else seemed exactly the same.

  • Use b2 command line to update the cors rule.
b2 update-bucket --corsRules '[          
  {                                        
      "corsRuleName": "downloadFromAnyOriginWithUpload", 
      "allowedOrigins": [
          "*"                                                          
      ],
      "allowedHeaders": [
          "*"
      ],
      "allowedOperations": [
          "s3_put", "s3_post", "s3_head", "s3_get"   
      ],
      "maxAgeSeconds": 3600
  }
]' your_bucket_name allPrivate
  def presigned_upload(opts) do
    key = Keyword.fetch!(opts, :key)
    max_file_size = opts[:max_file_size] || 10_000_000
    expires_in = opts[:expires_in] || 7200
    content_type = MIME.from_path(key)

    uri = "https://s3.eu-central-003.backblazeb2.com/your_bucket_name/#{URI.encode(key)}"

    url = :aws_signature.sign_v4_query_params(
      b2_access_key_id,
      b2_application_key,
      "eu-central-003",
      "S3",
      :calendar.universal_time(),
      "PUT",
      uri,
      ttl: expires_in,
      uri_encode_path: false,
      body_digest: "UNSIGNED-PAYLOAD"
    )
    {:ok, url}
  end
  • Use PUT in the js xhr upload
    const formData = new FormData()
    const {url} = entry.meta

    formData.append("file", entry.file)

    const xhr = new XMLHttpRequest()
    onViewError(() => xhr.abort())
    xhr.onload = () => {
      xhr.status >= 200 && xhr.status < 300 ? entry.progress(100) : entry.error()
    }
    xhr.onerror = () => entry.error()
    xhr.upload.addEventListener("progress", (event) => {
      console.log(event)
      if(event.lengthComputable){
        let percent = Math.round((event.loaded / event.total) * 100)
        if(percent < 100){ entry.progress(percent) }
      }
    })
    xhr.open("PUT", url, true)
    xhr.send(formData)

Trying the above did work for uploading from the liveview but as I said, the file itself in the storage bucket was corrupted. I gave up on this method after trying other permutations and combinations via different signing libraries like ex_aws, which all worked for uploads with the same file corruption issue in the bucket. Ultimately I rolled my own signing solution and use the b2 native apis with Cloudflare Workers (which I anyways needed to take advantage of the unmetered download bandwidth for the CDN alliance between Backblaze and Cloudflare). This is what I’m doing currently.

  • Have a shared secret key (on phoenix app and cloudflare worker) used for signing.
  • Use custom json as message for adding the details that needs to be verified for uploading
  • Sign using the hmac algorithm
  def presigned_upload_url(opts) do
    key = Keyword.fetch!(opts, :key)
    secret = "shared_secret"
    expires_at = DateTime.add(DateTime.utc_now(), 2, :hour) |> DateTime.to_iso8601()

    message =
      Jason.encode!(%{
        "uid" => uid,
        "file" => key,
        "exp" => expires_at
        # add more details like content size etc which you can verify during upload
      })

    signature = :crypto.mac(:hmac, :sha256, secret, message) |> Base.encode64()
    path = "#{signature}|#{message}" |> Base.encode64()

    {:ok, "https://your_cloudflare_workers_location/file/#{path}"}
  end
  • On cloudflare worker side
    • Receive the request and verify the signature using the same secret key
    • If the signature is verified proceed with upload/download using b2 native api
    • Save the response for b2 native api authorization in the Cloudflare KV for a day to save cost
// signingKey.ts
export default async function signingKey(signingSecret: string) {
  return await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(signingSecret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign", "verify"]
  );
}
// ------------------

// verifySignature.ts
export default async function verifySignature(
  signingKey: CryptoKey,
  signature: string,
  message: string
) {
  const sigBuf = Uint8Array.from(atob(signature), (c) => c.charCodeAt(0));

  return crypto.subtle.verify(
    "HMAC",
    signingKey,
    sigBuf,
    new TextEncoder().encode(message)
  );
}
// ------------------

// formatPayload.ts
import { mapValues } from "lodash";

export type FormattedData = {
  uid: string | null;
  exp: Date | null;
  file: string;
  cdn: boolean;
};

export type FormattedPayloadResponse = {
  signature: string;
  message: string;
  data: FormattedData;
};

export default function formatPayload(
  payloadBase64: string
): FormattedPayloadResponse {
  const decodedPayload = atob(payloadBase64);
  const [signature, message] = decodedPayload.split(DELIMITER, 2);

  const data = formatData(JSON.parse(message));

  return { signature, message, data };
}

const DELIMITER = "|";

const DATA_FORMATTERS = {
  uid: (val: string) => val ?? null,
  exp: (val: string) => (val ? new Date(val) : null),
  file: (val: string) => val ?? null,
  cdn: (val: string | number) => (val === "0" || val === 0 ? false : !!val),
};

function formatData(data: Record<string, string>) {
  return mapValues<typeof DATA_FORMATTERS, any>(DATA_FORMATTERS, (fn, key) =>
    fn(data[key])
  );
}
// ------------------

// isValidPayload.ts
import { get } from "lodash";
import { FormattedData, FormattedPayloadResponse } from "./formatPayload";

export default function isValidPayload(
  formattedPayload: FormattedPayloadResponse
) {
  const signature = get(formattedPayload, "signature");
  const message = get(formattedPayload, "message");
  const data = get(formattedPayload, "data");

  return !!signature && !!message && isValidData(data);
}

function isValidData(data: FormattedData) {
  const filePath = get(data, "file");
  const expiresAt = get(data, "exp");
  const isCdn = get(data, "cdn");

  const filePathParts = filePath.split("/");
  const filePathValidForCdn = isCdn ? filePathParts[1] === "public" : true;

  return (
    !!filePath &&
    filePathValidForCdn &&
    (!isCdn ? isExpiryDateValid(expiresAt) : true)
  );
}

function isExpiryDateValid(expiresAt: Date | null) {
  return !!expiresAt && !isNaN(+expiresAt) && expiresAt.valueOf() >= Date.now();
}

// ------------------

// use something like Hono https://hono.dev/ to run a lightweight web server on the Cloudflare workers
// middleware/verifySignedRequest.ts
import { MiddlewareHandler } from "hono";
import { HTTPException } from "hono/http-exception";
import { Env } from "../env";

import { formatPayload, isValidPayload, signingKey, verifySignature } from "../utils";

export default function verifySignedRequest(
  pathParamName = "payload"
): MiddlewareHandler<Env> {
  return async (ctx, next) => {
    const payload = ctx.req.param(pathParamName);

    if (!payload) {
      throw new HTTPException(400, { message: "INVALID_REQUEST" });
    }

    try {
      const formattedPayload = formatPayload(payload);

      if (!isValidPayload(formattedPayload)) {
        throw new HTTPException(400, {
          message: "INVALID_REQUEST",
        });
      }

      const { signature, message, data } = formattedPayload;
      const key = await signingKey(ctx.env.SIGNING_SECRET);

      if (!verifySignature(key, signature, message)) {
        throw new HTTPException(401, {
          message: "INVALID_SIGNATURE",
        });
      }

      ctx.set("signedRequestData", data); // use this later for downloads or uploads as per the need
    } catch (error) {
      throw new HTTPException(400, { message: "INVALID_REQUEST" });
    }

    // request is valid, proceed and use for uploads
    await next();
  };
}

// for uploading
app.put("/file/:payload", async (c) => {
  const formData = await c.req.formData();
  const file = formData.get("file") as unknown as Blob;
  const signedRequestData = c.get("signedRequestData");

  const data = await b2.api.uploadFile({
    KV: c.env.KV,
    baseUrl: c.env.B2_API_URL,
    keyId: c.env.B2_KEY_ID,
    applicationKey: c.env.B2_APPLICATION_KEY,
    bucketId: c.env.B2_BUCKET_ID,
    file: file,
    fileName: signedRequestData.file,
  });

  return c.json(data); // or some formatted subset of data you want to expose
});

I have deployed it on Cloudflare workers and testing it for signed upload/download use cases from phoenix liveview and it’s been working perfectly. The best part is I’m able to use Cloudflare’s builtin caching for downloads via fetch api to completely bypass the backblaze’s server in most cases for a file access request. All of this is still WIP at my end but let me know if anyone wants to have a peek into my private repo, I can give you access for a while to see the complete setup for Cloudflare workers.

thomas.fortes

thomas.fortes

Can you upload to B2 using something else like postman for example?

CORS is client related, so as long as the generated presigned url is correct the only code that matter is the javascript one for the S3 uploader, if you can upload from postman and not from liveview we can take a look and see what is happening.

Now from experience, if you’re using the examples from the docs for external uploads, try replacing the post with a put, most S3 compatible services that I used don’t handle presigned POST uploads very well unless you’re doing a multipart upload, which is a tad more complex than a simple POST upload.

Where Next?

Popular in Questions Top

sen
Hi All, I set a environment variables in dev.exs , like below code. when i start server, how can i set the ${enable} value? thanks. d...
New
chokchit
** (DBConnection.ConnectionError) connection not available and request was dropped from queue after 2733ms. You can configure how long re...
New
fireproofsocks
I’m working on defining a simple Ecto schema for a table (in PostGres), but I don’t see where I can define a column as NOT NULL. Conside...
New
pmjoe
I have a relationship of love and hate with Elixir. Lots of things are just absolutely right, but there are some things that are kind of ...
New
stefanluptak
Hello everybody, usually, I use a 29" ultra-wide monitor for VSCode which can easily accomodate explorer (files panel) + file with code ...
New
aalberti333
As the title describes, I’m trying to run Enum.map() over a list of key/value pairs, where the value is a map. My data looks like this: ...
New
lucidguppy
I have a super simple question about elixir - how would I take a file like this foo bar baz and output a new file that enumerates th...
New
itssasanka
Hi all, Trying to get some more clarity over utc_datetime and naive_datetime for Ecto: The documentation above suggests that while ...
New
dblack
I’ve got an issue with an app and I’ve no idea of how to troubleshoot it. I’m hoping someone here might have seen something similar. I p...
New
hariharasudhan94
Lets say I have map like this fetching from my database %{"_id" =&gt; #BSON.ObjectId&lt;58eb1a7a9ad169198c3dXXXX&gt;, "email" =&gt; ...
New

Other popular topics Top

senggen
Erlang/OTP 25 [erts-13.2.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] 15:22:35.803 [error] gen_event {lager_file_backend...
New
TunkShif
This post is an instruction guide to help you setup your Neovim for Elixir development from scratch. It includes general information on h...
274 41539 114
New
stefanchrobot
What’s the safe way to decode a JSON string into a struct? I want to avoid calling String.to_atom. Jason.decode can give me a map with st...
New
AngeloChecked
What learn first? Rust or Elixir Hi Elixir community! I’m here because i want learn a new language. I’m a junior developer and mainly i ...
New
pmjoe
I have a relationship of love and hate with Elixir. Lots of things are just absolutely right, but there are some things that are kind of ...
New
ashish173
I am using Ecto timestamps with postgres, I can see the timestamps() use the :naive_dateime but for my use case I wanted to store the ti...
New
rms.mrcs
Hi, I need to transform a list of numbers into a map where the keys are the indexes and the values are the original values of the list. ...
New
klo
Got a question about when to concat vs. prepending items to list then reversing to achieve appending. So i know lists boil down to [1 | ...
New
axelson
This post is a wiki (feel free to hit the edit button near the bottom right of this post to add your own changes!) This post collects co...
239 47930 226
New
svb
Hi! Currently I want to submit a form by pressing the Enter key. However, since my input field is of type “textarea” this is just adds a...
New

We're in Beta

About us Mission Statement