Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add passing an URL and query string params to resize an image? #127

Open
ndrean opened this issue Sep 27, 2023 · 3 comments
Open

add passing an URL and query string params to resize an image? #127

ndrean opened this issue Sep 27, 2023 · 3 comments
Labels
elixir Pull requests that update Elixir code enhancement New feature or enhancement of existing functionality technical A technical issue that requires understanding of the code, infrastructure or dependencies

Comments

@ndrean
Copy link

ndrean commented Sep 27, 2023

I added today the following functionality to my toy fork: if a picture is served, you pass a GET request to the endpoint with the URL in the query string and get back a link to a resized WEBP picture from S3. I found some pictures that were served and wanted to use them.

I choose WEBP format to limit traffic on S3 and bandwidth usage on mobile (and canIUse is :ok).

To run in a Livebook:

map= %{url: <the url.png>, w: 900, h: 600})

URI.parse("https://up-image.fly.dev/api")
|> URI.append_query(URI.encode(map)
|> URI.to_string()
|> Finch.get() 
|> Finch.build(MyApp.Finch)

or

curl  -X GET  https://up-image.fly.dev/api?url=<the url.jpeg>&h=600&w=600

It will deliver a json reply with the URL of the new file in S3.

Let me know if you find some interest.

Endpoint: (normally works 😀) https://up-image.fly.dev/api

@nelsonic nelsonic added enhancement New feature or enhancement of existing functionality technical A technical issue that requires understanding of the code, infrastructure or dependencies elixir Pull requests that update Elixir code labels Oct 2, 2023
@nelsonic
Copy link
Member

nelsonic commented Oct 2, 2023

@ndrean nice. 👌

Tried: https://up-image.fly.dev/api?url=https://world-celebs.com/public/media/resize/800x-/2019/8/5/porter-robinson-3.jpg&h=600&w=600

{"h":600,"w":600,"url":"https://dwyl-imgup.s3.eu-west-3.amazonaws.com/6E70A71E.webp",
"h_origin":800,"init_size":56172,"w_origin":800,"new_size":20980}

https://dwyl-imgup.s3.eu-west-3.amazonaws.com/6E70A71E.webp
image

Each change in width/height results in a new image:
https://up-image.fly.dev/api?url=https://world-celebs.com/public/media/resize/800x-/2019/8/5/porter-robinson-3.jpg&h=200&w=200

{"h":200,"w":200,"url":"https://dwyl-imgup.s3.eu-west-3.amazonaws.com/1BACCDFF.webp",
"h_origin":800,"init_size":55149,"w_origin":800,"new_size":3640}

https://dwyl-imgup.s3.eu-west-3.amazonaws.com/1BACCDFF.webp
image

This is a perfectly valid use case. Especially the use of .webp to optimise storage/bandwidth. ✅

As outlined in #91 (comment) I still prefer the idea of having a single version of an image in storage
and then using URL params w=200 to request a smaller size
and caching that request on the CDN for speed.
i.e. having a distinct/different URL for each different size of image is not desirable for us. 🔗

@ndrean
Copy link
Author

ndrean commented Oct 2, 2023

Thanks for your evaluation.

I have to better understand how you compute a resized image and transfer the resized image to the CDN, and then how do you call a resized image when used.

EDIT: I think that I can set up a DNS on Cloudfare, and keep the app hosted on Fly.io. Then instead of using S3, I can use R2. Then I think I can just add this domain as Cloudfare will cache the files. Now how to use R2 instead of S3. Probably client -> R2 must not be too difficult (?), but this would be only for the original image. Since I need to transform the images, I need client -> elixir-app -> R2, and able to interact with R2. The whole R2 API to explore.

@ndrean
Copy link
Author

ndrean commented Oct 2, 2023

I added another functionality: a POST endpoint.

From a client - the browser -, you want to upload multiple files and you get a JSON response with a simple fetch request. Phoenix parser only accepts one file. See build in parsers. You don't have an allow_upload equivalent as in LiveView.

@nelsonic The code below might interest you as this is not standard. The "secret" is to build your own multipart parser that will effectively parse a FormData. The idea is to exchange the key used as the input for a file in the FormData (it will the same, namely the "name" attribute of the input) to a new indexed one that you create. That's it. The cool part is that you are "almsot" independant on how the front-end coded it, just a FormData (see the HTML example below, 2 lines of JS). I still request to use "w" if you want a specific resize.

defmodule Plug.Parsers.FD_MULTIPART do
  @multipart Plug.Parsers.MULTIPART

  def init(opts) do
    opts
  end

  def parse(conn, "multipart", subtype, headers, opts) do
    length = System.fetch_env!("UPLOAD_LIMIT") |> String.to_integer()
    opts = @multipart.init([length: length] ++ opts)
    @multipart.parse(conn, "multipart", subtype, headers, opts)
  end

  def parse(conn, _type, _subtype, _headers, _opts) do
    {:next, conn}
  end

  def multipart_to_params(parts, conn) do
    case filter_content_type(parts) do
      nil ->
        {:ok, %{}, conn}

      new_parts ->
        acc =
          for {name, _headers, body} <- Enum.reverse(new_parts),
              reduce: Plug.Conn.Query.decode_init() do
            acc -> Plug.Conn.Query.decode_each({name, body}, acc)
          end

        {:ok, Plug.Conn.Query.decode_done(acc, []), conn}
    end
  end

  def filter_content_type(parts) do
    filtered =
      parts
      |> Enum.filter(fn
        {_, [{"content-type", _}, {"content-disposition", _}], %Plug.Upload{}} = part ->
          part

        {_, [_], _} ->
          nil
      end)

    l = length(filtered)

    case l do
     # user pressed enter without any files => do nothing
      0 ->
        nil

      _ ->
       # get the none "content-type" inputs
        other = Enum.filter(parts, fn elt -> !Enum.member?(filtered, elt) end)

        # get the key used to name the files
        key = elem(hd(filtered), 0)
       # build a new list of keys
        new_keys = keys = Enum.map(1..l, fn i -> key <> "#{i}" end)

        # and exchange the "old" key to the new indexed one. The keys will be unique this way.
        f =
          Enum.zip_reduce([filtered, new_keys], [], fn elts, acc ->
            [{_, headers, content}, new_key] = elts
            [{new_key, headers, content} | acc]
          end)

       # rebuild the "parts"
        f ++ other
    end
  end
end

To use this beast, "just" add to your API pipeline:

#router
pipeline :api do
    plug :accepts, ["json"]

    plug CORSPlug,
      origin: ["*"]

    plug Plug.Parsers,
      parsers: [:urlencoded, :my_multipart, :json],
      pass: ["image/jpg", "image/png", "image/webp", "iamge/jpeg"],
      json_decoder: Jason,
      multipart_to_params: {Plug.Parsers.FD_MULTIPART, :multipart_to_params, []},
      body_reader: {Plug.Parsers.FD_MULTIPART, :read_body, []}
  end

  scope "/api", UpImgWeb do
    pipe_through :api
    get "/", ApiController, :create
    post "/", ApiController, :handle
  end

To test this quickly, it is easy: create from the code an "index.html" and serve it. I request to use "w" and a checkbox named "thumb" if you want a thumbnail (default is 100px).

<html>
  <body>
    <form
      id="f"
      action="https://up-image.fly.dev/api"
      method="POST"
      enctype="multipart/form-data"
    >
      <input type="file" name="file" multiple />
      <input type="number" name= "w"/>
      <input type="checkbox" name="thumb"/>
      <button form="f">Upload</button>
    </form>

    <script>
      const form = ({ method, action } = document.forms[0]);
      form.onsubmit = async (e) => {
        e.preventDefault();
        return fetch(action, { method, body: new FormData(form) })
          .then((r) => r.json())
          .catch(console.log);
      };
    </script>
  </body>
</html>
Screenshot 2023-10-03 at 18 04 03

You will get a JSON response

Screenshot 2023-10-03 at 18 09 56

All async process to S3, thanks to Elixir. It was a real pleasure to code this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
elixir Pull requests that update Elixir code enhancement New feature or enhancement of existing functionality technical A technical issue that requires understanding of the code, infrastructure or dependencies
Projects
None yet
Development

No branches or pull requests

2 participants