{"id":15725937,"url":"https://github.com/ndrean/upimage","last_synced_at":"2025-03-31T01:27:16.168Z","repository":{"id":195098704,"uuid":"692414408","full_name":"ndrean/UpImage","owner":"ndrean","description":"Phoenix  app serving transformed images  to a bucket (webapp + api)","archived":false,"fork":false,"pushed_at":"2023-10-28T13:33:42.000Z","size":39553,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2024-04-13T22:00:12.061Z","etag":null,"topics":["api","image-classification","image-processing","libvips","phoenix-liveview","s3","upload"],"latest_commit_sha":null,"homepage":"https://up-image.fly.dev","language":"Elixir","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/ndrean.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2023-09-16T12:03:16.000Z","updated_at":"2023-10-26T09:10:53.000Z","dependencies_parsed_at":"2024-10-03T22:35:06.778Z","dependency_job_id":null,"html_url":"https://github.com/ndrean/UpImage","commit_stats":null,"previous_names":["ndrean/upimage"],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ndrean%2FUpImage","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ndrean%2FUpImage/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ndrean%2FUpImage/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/ndrean%2FUpImage/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/ndrean","download_url":"https://codeload.github.com/ndrean/UpImage/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":246402628,"owners_count":20771350,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["api","image-classification","image-processing","libvips","phoenix-liveview","s3","upload"],"created_at":"2024-10-03T22:25:05.786Z","updated_at":"2025-03-31T01:27:16.125Z","avatar_url":"https://github.com/ndrean.png","language":"Elixir","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Table of contents, Up-Image\n\n**STILL BUILDING**\n\n- [Table of contents, Up-Image](#table-of-contents-up-image)\n  - [About UpImg](#about-upimg)\n  - [API](#api)\n    - [GET endpoint](#get-endpoint)\n    - [POST endpoint](#post-endpoint)\n    - [Notes on Redirect and download in Streams](#notes-on-redirect-and-download-in-streams)\n  - [Todos](#todos)\n  - [WebApp](#webapp)\n  - [Backend](#backend)\n    - [Authentication, Authorization](#authentication-authorization)\n    - [Encrypt email and query it](#encrypt-email-and-query-it)\n    - [Credentials for Github, Google and AWS S3](#credentials-for-github-google-and-aws-s3)\n    - [Database migration schema](#database-migration-schema)\n    - [CSP](#csp)\n  - [Image transformation: Vix Vips](#image-transformation-vix-vips)\n  - [ML set up in Elixir](#ml-set-up-in-elixir)\n  - [Notes](#notes)\n    - [Notes on custom UploadWriter](#notes-on-custom-uploadwriter)\n    - [Notes on Custom MultiPart to allow multiple file upload](#notes-on-custom-multipart-to-allow-multiple-file-upload)\n    - [Streams of Uploads from S3](#streams-of-uploads-from-s3)\n    - [Serving SVG](#serving-svg)\n      - [Example: spinner](#example-spinner)\n    - [Handle failed `Task.async_stream`](#handle-failed-taskasync_stream)\n    - [**Reset of Uploads**](#reset-of-uploads)\n    - [Process Supervision - restart: :transient](#process-supervision---restart-transient)\n    - [Dev mode: file change watch](#dev-mode-file-change-watch)\n    - [File names: SHA256](#file-names-sha256)\n      - [Unique file upload](#unique-file-upload)\n  - [About configuration](#about-configuration)\n    - [Fly.io setup](#flyio-setup)\n    - [Startup Fly.io](#startup-flyio)\n    - [Temporary saved on the server](#temporary-saved-on-the-server)\n    - [Example of credentials:](#example-of-credentials)\n    - [IEx into the app](#iex-into-the-app)\n  - [Sitemap](#sitemap)\n  - [Cloudfare R2](#cloudfare-r2)\n\n## About UpImg\n\nThis app uploads images to S3 and transforms them into WEBP format to save on bandwidth and storage. The transformation is based on [libvips](https://www.libvips.org/) and the Elixir package [Vix.Vips](https://github.com/akash-akya/vix).\n\nCare is taken to maximise the usage of streams to limit the memory usage, especially when you upload multiple files. Care is taken to spawn processes whenever needed.\n\nWe propose an Image-To-Text implementation for **image tagging** or **caption creation** via a ML model. It proposes some tags and a caption to help the user to classify his pictures for him to retrieve them by tag. This might be a slow process, especially on a free-tier Fly.io machine. We set up the model with the env variable `MODEL`. It is built-in when the app starts.\n\nAbout **[CanIUse-WEBP?](https://caniuse.com/webp)**\n\nYou can use this app in two ways: API and WebApp.\n\n## API\n\nIt is limited to 5Mb images with dimension less than 4200x4000. The uploads are set with the `CONTENT-DISPOSITION=\"inline\"`.\n\nIt exposes the two endpoints at \u003chttps://up-image.fly.dev/api\u003e:\n\n- a GET endpoint. It accepts a query string with the \"url\" (which serves the picture you want) and possibly and \"w\" (the desired width) and optionally \"h\" (the height if you want to change the ratio). It accepts **redirects**, such as unsplash urls (`https://source.unsplash.com/\u003cphoto_id\u003e`).\n\n- a POST endpoint. It accepts a payload with \"multipart\" - for **multiple** files with a FormData. Use the key **\"w\"** to specify the width to resize the file and the key \"thumb\" as a checkbox to produce a thumbnail (standard 100px). It also accepts a key **\"pred\"** if you want ot add a caption description found by the ML model.\n\nThey return a json response with a link to a resized WEBP picture from S3 along with informations on the original file and the new file.\n\nWe use two consecutive strategies to ensure that the upload is a picture.\n\n- we firstly use **[libmagic](https://packages.debian.org/sid/libmagic-dev)** via [gen_magic](https://github.com/evadne/gen_magic). It works with **magic numbers**. It is added as a depency in the Dockerfile. We run this as a worker in order not to reload the C code on each call.\n\n- if this test is positive, we then run [ExIamgeInfo](https://github.com/Group4Layers/ex_image_info) to confirm by matching on the type of data. It does not use magic number but rather reads the content of the file. Note that this gives a `Sobelow` warning since we read external data.\n\nThis should assure that we receive a file of type \"image\" with the desired format: `[\"webp\", \"jpeg\", \"\"jpg\"]`.\n\n### GET endpoint\n\n- GET: copy and paste an URL\n  To upload the 4177x3832-5MB image \u003chttps://source.unsplash.com/QT-l619id6w\u003e to S3, and convert it into a WEBP image of width=300, you pass into the query string the \"url\", and possibly the new desired width \"w=70\".\n\n```bash\ncurl -X GET http://localhost:4000/api\\?url\\=https://source.unsplash.com/QT-l619id6w\\\u0026w\\=300\\\u0026pred\\=on\n```\n\n```bash\n{\"h\":200,\"w\":300,\"url\":\"https://dwyl-imgup.s3.eu-west-3.amazonaws.com/AA29E5B0.webp\",\"init_size\":213370,\"w_origin\":1080,\"h_origin\":720,\"predictions\":\"paddle, boat paddle\",\"new_size\":14424,\"url_origin\":\"https://source.unsplash.com/QT-l619id6w\"}\n\n```\n\nIf successful, you will receive a json response: `{\"url\": \"https://xxx.amazonaws.com/xxxx/new_file.webp}` or `{\"error: reason}`. This link is valid 1 hour.\n\n### POST endpoint\n\nYou can use the POST endpoint simply with a `fetch({method: 'POST'})` from the browser.\n\nA minimalist test: save the file below as \"index.html\" and `serve` it. You can select multiple files to upload to S3 to test the endpoint.\n\n```html\n\u003chtml\u003e\n  \u003cbody\u003e\n    \u003cform\n      id=\"f\"\n      action=\"https://up-image.fly.dev/api\"\n      method=\"POST\"\n      enctype=\"multipart/form-data\"\n    \u003e\n      \u003cinput type=\"file\" name=\"file\" multiple /\u003e\n      \u003cinput type=\"number\" name=\"w\" /\u003e\n      \u003cbutton form=\"f\"\u003eUpload\u003c/button\u003e\n    \u003c/form\u003e\n\n    \u003cscript\u003e\n      const form = ({ method, action } = document.forms[0]);\n      form.onsubmit = async (e) =\u003e {\n        e.preventDefault();\n        return fetch(action, { method, body: new FormData(form) })\n          .then((r) =\u003e r.json())\n          .catch(console.log);\n      };\n    \u003c/script\u003e\n  \u003c/body\u003e\n\u003c/html\u003e\n```\n\nYou will receive a JSON response as a list:\n\n```js\n{\"data\":[\n  {\"h\":39,\"w\":100,\"url\":\"https://dwyl-imgup.s3.eu-west-3.amazonaws.com/0E3A7F41.webp\",\"init_size\":340362,\"w_origin\":1152,\"h_origin\":452,\"new_size\":5300,\"filename\":\"test1\"},\n  {\"h\":68,\"w\":100,\"url\":\"https://dwyl-imgup.s3.eu-west-3.amazonaws.com/F09F2736.webp\",\"init_size\":82234,\"w_origin\":960,\"h_origin\":656,\"new_size\":2028,\"filename\":\"test2\"}\n  ]\n}\n```\n\n### Notes on Redirect and download in Streams\n\nWe send a request and build a stream with the body since we want to write it into a file. This limits the memory usage.\n\nWhen we have a [redirection](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/302), we get a status 302 and a header `Location`. We exploit this here.\n\n```elixir\n{:ok, path} = Plug.Upload.random_file(\"stream\")\n\ndef follow_redirect(url, path) do\n    Finch.build(:get, url)\n    |\u003e Api.stream_request_into(path)\nend\n```\n\n```elixir\ndef stream_request_into(req, path) do\n  {:ok, file} = File.open(path, [:binary, :write])\n\n  streaming = stream_write(req, file)\n\n  case File.close(file) do\n    :ok -\u003e\n      case streaming do\n        {:ok, _} -\u003e\n          {:ok, path}\n\n        {:error, reason} -\u003e\n          {:error, reason}\n      end\n\n    {:error, reason} -\u003e\n      {:error, reason}\n  end\nend\n```\n\nWe write into a file stream by stream. When we have a redirect, we have a `{\"location\", location}` header.\nThe function [Finch.stream](https://hexdocs.pm/finch/Finch.html#stream/5) uses 3 functions ahd each receives 2 arguments: a tuple and an accumulator. In the first function, we return the \"status\" as the accumulator. In the second function, we parse the headers and return the whole headers or the `Location` header depending if the acc = status = 302. The last function receives the headers in the accumulator. In the case where we have the value \"location\" in the `Location` header, we use recursion. If not, we write into the file so we do't return the whole binary.\n\n\u003chttps://github.com/sneako/finch/blob/ff6a162174aa391cf4c7c7d6b59afb420cfec259/lib/finch.ex#L231\u003e\n\nA word on IOData : \u003chttps://elixirforum.com/t/understanding-iodata/3932/3\u003e\n\n```elixir\n\ndef follow_redirect(url, path) do\n  Finch.build(:get, url)\n  |\u003e stream_request_into(path)\nend\n\ndef stream_request_into(req, path) do\n    {:ok, file} = File.open(path, [:binary, :write])\n\n    streaming = stream_write(req, file)\n\n    case File.close(file) do\n      :ok -\u003e\n        case streaming do\n          {:ok, _} -\u003e\n            {:ok, path}\n\n          {:error, reason} -\u003e\n            {:error, reason}\n        end\n\n      {:error, reason} -\u003e\n        {:error, reason}\n    end\n  end\n\n\ndef stream_write(req, file) do\n    fun = fn\n      {:status, status}, acc -\u003e\n        status\n\n      {:headers, headers}, status -\u003e\n        handle_headers(headers, status)\n\n      {:data, data}, headers -\u003e\n        handle_data(data, headers, file)\n    end\n\n    Finch.stream(req, UpImg.Finch, nil, fun)\n  end\n\n  defp handle_headers(headers, 302) do\n    Enum.find(headers, \u0026(elem(\u00261, 0) == \"location\"))\n  end\n\n  defp handle_headers(headers, 200) do\n    headers\n  end\n\n  defp handle_headers(_headers, _status) do\n    {:halt, \"bad redirection\"}\n  end\n\n  defp handle_data(_data, {\"location\", location}, file) do\n    Finch.build(:get, location) |\u003e Api.stream_write(file)\n  end\n\n  defp handle_data(_data, {:halt, \"bad redirection\"}, _file) do\n    {:error, \"bad redirection\"}\n  end\n\n  defp handle_data(data, _headers, file) do\n    case IO.binwrite(file, data) do\n      :ok -\u003e :ok\n      {:error, reason} -\u003e {:error, reason}\n    end\n  end\n```\n\n## Todos\n\n- ~~implement One Tap newest version~~\n- ~~\"image caption\" via ML~~\n- ~~provide an API with GET via query string for URLS~~\n- ~~capable of using redirected \"unsplash\" type URLs~~\n- ~~provide an API with POST via FormData for mulitple files~~\n- secure the API with a token provided by the app to a registered user. You can register simply via Google or Github, no friction. You then will request a token (valid 1 day).\n- rate limit the API.\n- implement a total MB uploaded per user.\n\n## WebApp\n\nYou can use it in two ways:\n\n- select files from your device you want to upload to S3,\n- copy/paste a link of an image you want to upload to S3 (_UX en cours_)\n\nYou select files from your device and the server will produce a thumbnail (for the display) and a resized file. All will be WEBP and displayed in the browser.\n\nOnce you load images from your device, 2 transformations are made: a thumbnail (of max dimension 200px) and a resize into max **1440x726** (if needed).\nThe transformation is based on the image processing library [libvips](https://www.libvips.org/) via the Elixir extension [Vix.Vips](https://github.com/akash-akya/vix).\n\nSince we want ot display/serve the image, the previews are saved on disk into temporary files.\nThey are waiting for your decision to upload the image to S3 or not.\n\nThese files are pruned after a few minutes of inactivity and if you navigate away.\nA scrubber also cleans all \"old\" files (at most one hour old).\n\n\u003cimg width=\"614\" alt=\"Screenshot 2023-09-27 at 16 51 05\" src=\"https://github.com/ndrean/UpImage/assets/6793008/0838f94e-c216-442e-8864-9ef04d0e9eab\"\u003e\n\n## Backend\n\n### Authentication, Authorization\n\nMinimum friction: full authorization for authenticated users. You can use `Github` or `Google One-Tap` authentication; no password is required as it is a \"find_or_create\" process.\n\nSignin with [Google One Tap](https://developers.google.com/identity/gsi/web/guides/overview) now POST a `url-encoded` body instead of JSON. The code is updated to parse this.\n\n### Encrypt email and query it\n\nThe email is encrypted (reversible) but is not searchable (because repeating an encryption on an input will walways given a different output - ciphertext - thus unique, so you can never match it). Its hash version (via `:sha256`) is however searchable because repeating a hash on an input will always give the same output, a hash. It _cannot_ -in terms of computation time, ie no better than a random search - be \"unhashed\" (ie reversed to plaintext), thus is considered as \"safe\".\n\n[A blog on Erlang crypto](https://www.thegreatcodeadventure.com/elixir-encryption-with-erlang-crypto/)\n\n[Very useful blog on email encryption and hash](https://github.com/dwyl/fields#why-do-we-have-both-emailencrypted-and-emailhash-)\n\nWe used: `[{:cloak, \"~\u003e 1.1\"},{:cloak_ecto, \"~\u003e 1.2\"}]` and `{:argon2_elixir, \"~\u003e 3.2\"}`. Do not use `Bcrypt` because you want to be able to use a pre-defined hash to query the email.\n\n[Usage of Cloak.Ecot.Binary](https://hexdocs.pm/cloak_ecto/Cloak.Ecto.Binary.html#content) to encrypt the email.\n\nThe salt is set by an env var. For the key rotation, read \u003chttps://github.com/dwyl/phoenix-ecto-encryption-example#33-key-rotation\u003e\n\nThe (easiest) set up is:\n\n```elixir\n# Application.ex\nchildren = [\n  ...\n  UpImg.MyVault\n]\n\ndefmodule UpImg.Encrypted.Binary do\n  use Cloak.Ecto.Binary, vault: UpImg.MyVault\nend\n\ndefmodule UpImg.MyVault do\n  use Cloak.Vault, otp_app: :up_img\n\n  @impl GenServer\n  def init(config) do\n    config =\n      Keyword.put(config, :ciphers,\n        default: {\n          Cloak.Ciphers.AES.GCM,\n          tag: \"AES.GCM.V1\", key: decode_env!(), iv_length: 12\n        }\n      )\n\n    {:ok, config}\n  end\n\n  defp decode_env!, do: System.get_env(\"CLOAK_KEY\") |\u003e Base.decode64!()\nend\n```\n\n[Usage of the Ecto Type Cloak.Ecto.SHA256](https://hexdocs.pm/cloak_ecto/Cloak.Ecto.SHA256.html#module-usage)\nThe email is encrypted and the email is hashed. We use the Cloak special types for the Ecto schema\n\n```elixir\nschema \"users\" do\n  field :email, UpImg.Encrypted.Binary\n  field :hashed_email, Cloak.Ecto.SHA256\n  field :provider, :string\n  field :name, :string\n  field :username, :string\n  field :confirmed_at, :naive_datetime\n\n  has_many :urls, Url\n  timestamps()\nend\n```\n\n:exclamation: Note that the migration (to Postgres) should use the type `:binary` and _not_ the type `:string`.\n\n```elixir\n# migration\ncreate table(:users) do\n  add :email, :binary, null: false\n  add :hashed_email, :binary, null: false\n  add :username, :string, null: false\n  add :name, :string\n  add :provider, :string\n  add :confirmed_at, :naive_datetime\n\n  timestamps()\nend\ncreate unique_index(:users, [:hashed_email, :provider], name: :hashed_email_provider)\n```\n\nA changeset for a provider is shown below. Don't pass in a hash of the email. This is _enforced_ in the `put_change`.\n\n```elixir\ndef google_registration_changeset(profil) do\n  params = %{\n    \"email\" =\u003e profil.email,\n    \"provider\" =\u003e \"google\",\n    \"name\" =\u003e profil.name,\n    \"username\" =\u003e profil.given_name\n  }\n\n  changeset =\n    %User{}\n    |\u003e cast(params, [:email, :hashed_email, :name, :username, :provider])\n    |\u003e validate_required([:email, :name, :username])\n    |\u003e unique_constraint([:hashed_email, :provider], name: :hashed_email_provider)\n\n  put_change(changeset, :hashed_email, get_field(changeset, :email))\nend\n```\n\nWe can check if the user should be created or exists with the simple query:\n\n```elixir\ndef get_user_by_provider(provider, email) when provider in [\"github\"] do\n  query =\n    from(u in User,\n      where:\n        u.provider == ^provider and\n          u.hashed_email == ^email\n    )\n\n  Repo.one(query)\nend\n```\n\n### Credentials for Github, Google and AWS S3\n\n- Create credentials for Github: \u003chttps://github.com/settings/developers\u003e and pass callback URL\n\n\u003cimg width=\"666\" alt=\"Screenshot 2023-09-19 at 21 09 22\" src=\"https://github.com/ndrean/UpImage/assets/6793008/35f41cc6-e8ba-4536-8384-d236fbe0d133\"\u003e\n\n- Create credentials for Google: \u003chttps://console.cloud.google.com/apis/credentials/\u003e and pass Authorized Javascript origins and Authorized redirects URLs. The local set up is shown below (!! you need `http://localhost:4000` AND `http://localhost` in the authorised Javascript origin).\n\n\u003cimg width=\"478\" alt=\"Screenshot 2023-10-05 at 14 05 32\" src=\"https://github.com/ndrean/UpImage/assets/6793008/6f1ecd68-e2c8-4587-a039-d5c46277debb\"\u003e\n\n- set up callback URI for both,\n- come back to theses settings once you get the app deployed (https://up-image.fly.dev)\n\n### Database migration schema\n\nPostgres database to persist users' data and uploaded URLs.\n\n```bash\nmix ecto.gen.erd\ndot -Tpng ecto_erd.dot \u003e erd.png\n```\n\n![ERD](erd.png)\n\n### CSP\n\nFollowing `Sobelow` and Google's recommendations, a Content-Security-Policy is set up in the browser pipeline (don't put a \"newline\" as in the example below).\n\n```elixir\nplug :put_secure_browser_headers,\n  %{\n    \"content-security-policy\" =\u003e\n      \"img-src data: w3.org/svg/2000 'self' https://*.googleapis.com/ https://s3.eu-west-3.amazonaws.com/; connect-src 'self' https://www.googleapis.com/oauth2 https://accounts.google.com;\n      script-src-elem https://www.googleapis.com/oauth2 https://accounts.google.com 'self';\n      frame-src 'self' https://accounts.google.com;\n      style-src-elem https://accounts.google.com 'self' 'unsafe-inline';\n      default-src 'self'\"\n  }\n```\n\nA [blog about CSP and Elixir](https://furlough.merecomplexities.com/elixir/phoenix/security/2021/02/26/content-security-policy-configuration-in-phoenix.html)\n\n## Image transformation: Vix Vips\n\nIt uses [Vix Vips](https://github.com/akash-akya/vix) (NIF based binding for `libvips`) to transform inputs into WEBP format, resize and produce thumbnails on the server from a binary.\n\nWe target the device dimensions to respond. We produce 3 files: a thumbnail of max dim 200px, a file resized to 1440x??? (ratio preserved), and a file adapted to the device.\n\nAll tasks are run concurently.\n\n- The uploader writer has been changed to `ChunkWirter` to deliver an in-memory binary to avoid writting to disk.\n- the binary is loaded by **Vix** with `Vix.Vips.Image.new_from_buffer`\n- we then produce a thumbnail with `Vix.Vips.Operation.thumbnail_image`\n- we also resize it with `Vix.Vips.resize` once we get the max full-screen size of the device via a Javascript hook.\n- we finish the job by temporarily saving both files on disk into WEBP format with `Vix.Vipx.Operation.webpsave`.\n\n## ML set up in Elixir\n\n## Notes\n\n### Notes on custom UploadWriter\n\nWe want to pass a binary in memory instead of a random tmp file on disk. We re-write the [UploadWriter](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.UploadWriter.html) passed in `allow_upload/3` under the option `:writer`. We want to do this because `Vix.Vips` can read from buffer so this saves on I/O.\n\n```elixir\ndefmodule ChunkWriter do\n @behaviour Phoenix.LiveView.UploadWriter\n  require Logger\n\n  @impl true\n  def init(_opts) do\n    {:ok, %{file: \"\"}}\n  end\n\n  # it sends back the \"meta\"\n  @impl true\n  def meta(state), do: state\n\n  @impl true\n  def write_chunk(chunk, state) do\n    {:ok, Map.update!(state, :file, \u0026(\u00261 \u003c\u003e chunk))}\n  end\n\n  @impl true\n  def close(state, :done) do\n    {:ok, state}\n  end\n\n  def close(state, reason) do\n    Logger.error(\"Error writer-----#{inspect(reason)}\")\n    {:ok, Map.put(state, :errors, inspect(reason))}\n  end\nend\n```\n\n### Notes on Custom MultiPart to allow multiple file upload\n\nPhoenix accepts multipart encoded (`FormData`) and sets up a `Plug.Upload` mechanism to write a temporary file on disk. It only accepts one file since it uses the same key.\n\nTo change this to accept multiple files, one way is to create multiple keys, one for each file. This can be done by building a [custom multiparser](https://hexdocs.pm/plug/Plug.Parsers.MULTIPART.html#module-multipart-to-params).\n\n```elixir\ndefmodule Plug.Parsers.FD_MULTIPART do\n  @multipart Plug.Parsers.MULTIPART\n\n  def init(opts) do\n    opts\n  end\n\n  def parse(conn, \"multipart\", subtype, headers, opts) do\n    length = System.fetch_env!(\"UPLOAD_LIMIT\") |\u003e String.to_integer()\n    opts = @multipart.init([length: length] ++ opts)\n    @multipart.parse(conn, \"multipart\", subtype, headers, opts)\n  end\n\n  def parse(conn, _type, _subtype, _headers, _opts) do\n    {:next, conn}\n  end\n\n  def multipart_to_params(parts, conn) do\n    case filter_content_type(parts) do\n      nil -\u003e\n        {:ok, %{}, conn}\n\n      new_parts -\u003e\n        acc =\n          for {name, _headers, body} \u003c- Enum.reverse(new_parts),\n              reduce: Plug.Conn.Query.decode_init() do\n            acc -\u003e Plug.Conn.Query.decode_each({name, body}, acc)\n          end\n\n        {:ok, Plug.Conn.Query.decode_done(acc, []), conn}\n    end\n  end\n\n  def filter_content_type(parts) do\n    # find the headers than contain \"content-type\"\n    filtered =\n      parts\n      |\u003e Enum.filter(fn\n        {_, [{\"content-type\", _}, {\"content-disposition\", _}], %Plug.Upload{}} = part -\u003e\n          part\n\n        {_, [_], _} -\u003e\n          nil\n      end)\n\n    l = length(filtered)\n\n    case l do\n      0 -\u003e\n        # we don't want to submit without files as the rest of the code breaks\n        nil\n\n      _ -\u003e\n        # extract the others\n        other = Enum.filter(parts, fn elt -\u003e !Enum.member?(filtered, elt) end)\n\n        # get the key identifier name\n        key = elem(hd(filtered), 0)\n        # build a list of indexed identifiers from the key: \"key1\", \"key2\",...\n        new_keys = keys = Enum.map(1..l, fn i -\u003e key \u003c\u003e \"#{i}\" end)\n\n        # we do the following below: we change [{key, xxx,xxx}, {key,xxx,xxx}, ...] to\n        # [{key1, xxx,xxx}, {key2,xxx,xxx},...] so we get unique identifiers\n        f =\n          Enum.zip_reduce([filtered, new_keys], [], fn elts, acc -\u003e\n            [{_, headers, content}, new_key] = elts\n            [{new_key, headers, content} | acc]\n          end)\n\n        f ++ other\n    end\n  end\nend\n```\n\nThen you declare it in the router in the `API` pipeline.\n\n```elixir\n#router.ex\npipeline :api do\n  plug :accepts, [\"json\"]\n\n  plug CORSPlug,\n    origin: [\"http://localhost:3000\", \"http://localhost:4000\", \"https://dwyl-upimage.fly.dev\"]\n\n  plug Plug.Parsers,\n    parsers: [:urlencoded, :my_multipart, :json],\n    pass: [\"image/jpg\", \"image/png\", \"image/webp\", \"iamge/jpeg\"],\n    json_decoder: Jason,\n    multipart_to_params: {Plug.Parsers.FD_MULTIPART, :multipart_to_params, []},\n    body_reader: {Plug.Parsers.FD_MULTIPART, :read_body, []}\nend\n```\n\n### Streams of Uploads from S3\n\nAll the users' uploaded files are displayed as `streams``. To limit data usage, a thumbnail (5-10 kB) is displayed which links to the resized/webp format of the original image.\n\nNotice that when we upload a file to S3, we save the URL returned by S3 into the database. We then display all the uploaded files from S3 for this user by inserting this file into the stream. We need to pass a `%Phoenix.LiveVew.UploadEntry{}` struct - populated by the up-to-date data received from S3 - for `stream.insert` to work.\n\n```elixir\ndata =\n  Map.new()\n  |\u003e Map.put(:resized_url, map.resized_url)\n  |\u003e Map.put(:thumb_url, map.thumb_url)\n  |\u003e Map.put(:uuid, map.uuid)\n  |\u003e Map.put(:user_id, current_user.id)\n\ncase Url.changeset(%Url{}, data) |\u003e Repo.insert() do\n  {:ok, _} -\u003e\n    new_file =\n      %Phoenix.LiveView.UploadEntry{}\n      |\u003e Map.merge(data)\n\n    {:noreply, stream_insert(socket, :uploaded_files_to_S3, new_file)}\n```\n\n### Serving SVG\n\nYou can serve SVGs located in \"/priv/static/images\" (as `\u003cimg src={~p\"/my-svg.svg\"}/\u003e`) instead of polluting the HTML markup. Append the static list that Phoenix will server:\n\n```elixir\n#my_app_web.ex\n def static_paths, do:\n ~w(assets fonts images favicon.ico robots.txt image_uploads)\n```\n\nIn the CSP, use: `\"img-src w3.org/svg/2000;`\n\n#### Example: spinner\n\nJust followed this [excellent post](https://fly.io/phoenix-files/server-triggered-js/) from Fly.io.\n\nA [repo](https://github.com/n3r4zzurr0/svg-spinners/tree/main) with simple SVG spinners; it is passed as `\u003cimg scr=path\u003e` and the SVG is located in the \"/priv/static/images\" directory.\n\n### Handle failed `Task.async_stream`\n\nTo handle failed task in `Task.async_stream`, use `on_timeout: :kill_task` so that a failed task will send `{:exit, :timeout}`.\n\n### **Reset of Uploads**\n\nIn case a user loads a \"bad\" file\", you will get an error. We are not in the case of managing errors within the form. One solution to handle this case is to \"reduce\" and `cancel_upload` all the refs along the socket, then reset the \"uploaded_files_locally\" and perhaps send a message that describes the error (too many files, too large). This will reset the users' entries.\n\n```elixir\ndef are_files_uploadable?(image_list) do\n  error_list = Map.get(image_list, :errors)\n\n  case Enum.empty?(error_list) do\n    true -\u003e\n      true\n\n    false -\u003e\n      send(self(), {:upload_error, error_list})\n      false\n  end\nend\n```\n\n```elixir\ndef handle_info({:upload_error, error_list}, socket) do\n  errors =\n    error_list |\u003e Enum.reduce([], fn {_ref, msg}, list -\u003e [error_to_string(msg) | list] end)\n\n  send(self(), {:cancel_upload})\n  {:noreply, put_flash(socket, :error, inspect(errors))}\nend\n```\n\nand then:\n\n```elixir\ndef handle_info({:cancel_upload}, socket) do\n  # clean the uploads\n  socket =\n    socket.assigns.uploads.image_list.entries\n    |\u003e Enum.map(\u0026 \u00261.ref)\n    |\u003e Enum.reduce(socket, fn ref, socket -\u003e cancel_upload(socket, :image_list, ref) end)\n\n  {:noreply, assign(socket, :uploaded_files_locally, [])}\nend\n```\n\n### Process Supervision - restart: :transient\n\nWe want a process to be supervised and restart if fails, but we don't want this process to kill the parent LiveView. For example, when we ask to delete a file in S3, we don't want this to fail to avoid a difference with a database.\n\nAn example in a LiveBook:\n\n```elixir\nSupervisor.start_link(\n  [\n    {DynamicSupervisor, name: DynSup, strategy: :one_for_one},\n    {Registry, keys: :unique, name: MyRegistry},\n    {Task.Supervisor, name: MyTSup}\n  ],\n  strategy: :one_for_one,\n  name: MySupervisor\n)\n{:ok, pid1} = Task.start(fn -\u003e Process.sleep(10000); IO.puts \"ok\" end)\n{:ok, pid2} = Task.Supervisor.start_child(MyTSup, fn -\u003e Process.sleep(10000); IO.puts \"ok sup\" end, restart: :transient)\nIO.inspect({pid1, pid2})\n\n# check if pid2 is indeed supervised\nTask.Supervisor.children(MyTSup)\n\n# we kill pid2. The LV is not stopped, and pid2 will be restarted\nProcess.exit(pid2, :kill)\n\n\"ok\"\n\"ok sup\"\n```\n\n### Dev mode: file change watch\n\nTo stop rebuild when file changes, remove the folder \"image_uploads\" from the watched list by setting:\n\n```elixir\n# /config/dev.exs\nconfig :up_img, UpImgWeb.Endpoint,\n  live_reload: [\n    patterns: [\n      ~r\"priv/static/[^image_uploads].*(js|css|png|jpeg|jpg|gif|svg)$\",\n      ~r\"priv/gettext/.*(po)$\",\n      ~r\"lib/up_img_web/(controllers|live|components)/.*(ex|heex)$\"\n    ]\n  ]\n```\n\n### File names: SHA256\n\nFiles are named by their SHA256 hash (with `:crypto_hash`) so file names are (almost) unique.\n\n#### Unique file upload\n\nWe disabled the possibility to upload several times the same file. You can however upload it several times but you will get a warning that you attempt to save the same file. It has to be unique (to save on space).\n\n## About configuration\n\nTo properly configure the app (with Google \u0026 Github \u0026 AWS credentials):\n\n- set the env variables in \".env\" (and run `source .env`),\n- env vars are set up as a keyword list with eg `config :my_app, :google, client_id: System.get_env(...)` in \"/config/runtime.exs\" and \"/config/dev/exs\" and \"/config/test/exs\".\n- only hardcoded configuration is set up in \"/config/config.exs\", such as Google or Github callback URI (which is also set in the router).\n- in the app, most of the env vars are passed via a Task (`EnvReader`) into an ETS table to accelerate the reading. You get for example `EnvReader.google_id()`.\n\n### Fly.io setup\n\nYou can copy your **.env** file (without `export`!) into the CLI and push your secrets:\n\n```bash\nfly secrets set \u003cpaste here\u003e PORT=8080 ....\n```\n\nThe list is:\n\n- `PORT=8080`,\n- `SECRET_KEY_BASE` (via `mix phx.gen.secret`)\n- Github credentials generated [here](https://github.com/settings/developers): `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`,\n- Google credentials [Google console](https://console.cloud.google.com/apis/credentials/): `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`,\n- the email encrypting process (\"at rest\") key: `CLOAK_KEY`, generated by `32 |\u003e :crypto.strong_rand_bytes() |\u003e Base.encode64()` (cf [doc](https://hexdocs.pm/cloak_ecto/generate_keys.html#content)),\n- AWS S3 credentials: AWS_REGION`,`AWS_ACCESS_KEY_ID`, AWS_SECRET_ACCESS_KEY`, `AWS_S3_BUCKET`,\n- configure the old file scrubbing service period (base 1 hour): `PERIOD=1`\n- upload limit size: `UPLOAD_LIMIT=5000000`\n\nThe Postgres database is generated by Fly.io. You will get the safe string to connect to the database.\n\n### Startup Fly.io\n\n\u003cimg width=\"985\" alt=\"Screenshot 2023-09-27 at 14 40 07\" src=\"https://github.com/ndrean/UpImage/assets/6793008/d11cfaff-16be-4847-9d13-52823e912fbe\"\u003e\n\n### Temporary saved on the server\n\nWhen the user previews files, they are temporarilly saved on the server.\n\nWhen selected for upload to S3 (and uploaded), they are removed from the assigns and pruned from the disk.\n\nSince a user may quit the tab, files will be hanging. We have 2 guards to prevent holding unnecessary files on the server.\n\nWe set a hook which implements a browser listener on the navigation link. When clicked, it triggers a server `pushEvent` to clean the temporary assigns and the corresponding files on disk.\n\nAfter 10 minutes of inactivity **on a desktop**, the temporary files are pruned if the app is still open. This is run via a `hook` that runs a `setTimeout` in the browser when the upload tab is opened. A browser event resets the timeout. When triggered, it `pushEventTo` the server and cleans the temporary assigns and redirects.\n\n:exclamation: The previous guard does not work on mobile.\n\nWe set a `Process.send_after` to clean the temporary files after 3 minutes (an Env Var). Every new preview cancels the timer and sets a new one. This works even when the app is set in the background.\n\nSome edge cases may still remain. We run a reccurent task to remove all files older than one hour ago (with `File.stat`).\n\n```elixir\n#Application\nchildren = [\n  {Task,\n  fn -\u003e FileUtils.clean(every_ms: 1_000 * 60 * 60, older_than_seconds: 60 * 60 * 1) end}\n]\n```\n\n! Put no more than **one** per tag, usually a `\u003cdiv\u003e`, and place it in the beginning of the HTML markdown. You can create a `\u003cdiv\u003e` for this ‼️if needed.\n\n### Example of credentials:\n\n```bash\nPostgres cluster up-image-db created\nUsername: postgres\nPassword: \u003cFLY_PWD\u003e\nHostname: up-image-db.internal\nFlycast: fdaa:0:57e6:0:1::4\nProxy port: 5432\nPostgres port: 5433\nDatabase_URL: \u003cFLY_STRING\u003e\n```\n\n### IEx into the app\n\n\u003chttps://fly.io/docs/elixir/the-basics/iex-into-running-app/\u003e\n\n```bash\n\u003e fly ssh issue --agent\n\u003e fly ssh console --pty -C \"/app/bin/up_img remote\"\n\niex(up_img@683d47dcd65948)4\u003e\nApplication.app_dir(:up_img, [\"priv\", \"static\", \"image_uploads\"]) |\u003e File.ls!()\n```\n\n## Sitemap\n\n\u003chttps://andrewian.dev/blog/sitemap-in-phoenix-with-verified-routes\u003e\n\u003chttps://medium.com/@ricardoruwer/create-dynamic-sitemap-xml-in-elixir-and-phoenix-6167504e0e4b\u003e\n\n## Cloudfare R2\n\nInstall the CLI.\nSave your \"keyID\" and \"applicationKey\"\n\nSave this file locally, named \"b2_cors.json\" here:\n\n```json\n[\n  {\n    \"corsRuleName\": \"downloadFromAnyOrigin\",\n    \"allowedOrigins\": [\"https\", \"http://localhost:4000\"],\n    \"allowedHeaders\": [\"range\"],\n    \"allowedOperations\": [\n      \"b2_download_file_by_id\",\n      \"b2_download_file_by_name\",\n      \"b2_upload_file\",\n      \"b2_upload_part\",\n      \"s3_delete\",\n      \"s3_get\",\n      \"s3_post\",\n      \"s3_put\"\n    ],\n    \"exposeHeaders\": [\n      \"x-bz-content-sha1\",\n      \"X-Bz-File-Name\",\n      \"X-Bz-Part-Number\"\n    ],\n    \"maxAgeSeconds\": 3600\n  }\n]\n```\n\n```bash\n\u003e b2 authorize-account \u003ckeyID\u003e \u003capplicationKey\u003e\n\u003e b2 list-buckets\nup-image\n\u003e b2 update-bucket --corsRules \"$(\u003c./b2_cors.json)\" \u003cbucket\u003e allPublic\n```\n\n\u003chttps://dash.cloudflare.com/843179836f19f3543d8ed2866db92b5f/r2/cli?from=overview\u003e\n\nRun `pnpm create cladufare@latest`, choose a folder, say \"r2\", choose worker, and then `npx wrangler` to `npx wrangler r2 bucket create \u003cname\u003e`.\n\nIn CF/R2 dashboard, go to your bucket, and in \"settings\", in CORS-policy, add:\n\n```json\n[\n  {\n    \"AllowedOrigins\": [\n      \"http://localhost:3000\",\n      \"http://localhost:4000\",\n      \"https://up-image.fly.dev\"\n    ],\n    \"AllowedMethods\": [\"GET\", \"PUT\", \"POST\"],\n    \"AllowedHeaders\": [\"*\"],\n    \"ExposeHeaders\": []\n  }\n]\n```\n\n```elixir\n[\n  %{\n    base: \"dff3a13e58c2fcb2d1382f8f016e035405bad359\",\n    full_ref: \"#Reference\u003c0.0.575235.90977385.2484928518.78414\u003e\",\n    full_name: \"dff3a13e58c2fcb2d1382f8f016e035405bad359-m1440.webp\"\n  },\n  %{\n    base: \"dff3a13e58c2fcb2d1382f8f016e035405bad359\",\n    pred_ref: \"#Reference\u003c0.0.575235.90977385.2484928518.78487\u003e\",\n    ml_name: \"dff3a13e58c2fcb2d1382f8f016e035405bad359-m512.webp\"\n  },\n  %{\n    base: \"dff3a13e58c2fcb2d1382f8f016e035405bad359\",\n    thumb_ref: \"#Reference\u003c0.0.575235.90977385.2484928518.78510\u003e\",\n    thumb_name: \"dff3a13e58c2fcb2d1382f8f016e035405bad359-m200.webp\"\n  },\n  %{\n    base: \"1110df0436fd62b85bb8579695102f38ff034092\",\n    full_ref: \"#Reference\u003c0.0.575235.90977385.2484928515.120170\u003e\",\n    full_name: \"1110df0436fd62b85bb8579695102f38ff034092-m1440.webp\"\n  },\n  %{\n    base: \"1110df0436fd62b85bb8579695102f38ff034092\",\n    pred_ref: \"#Reference\u003c0.0.575235.90977385.2484928515.120244\u003e\",\n    ml_name: \"1110df0436fd62b85bb8579695102f38ff034092-m512.webp\"\n  },\n  %{\n    base: \"1110df0436fd62b85bb8579695102f38ff034092\",\n    thumb_ref: \"#Reference\u003c0.0.575235.90977385.2484928515.120256\u003e\",\n    thumb_name: \"1110df0436fd62b85bb8579695102f38ff034092-m200.webp\"\n  }\n]\n\n[\n  %{\n    base: \"1110df0436fd62b85bb8579695102f38ff034092\",\n    pred_ref: \"#Reference\u003c0.0.575235.90977385.2484928515.120244\u003e\",\n    thumb_ref: \"#Reference\u003c0.0.575235.90977385.2484928515.120256\u003e\",\n    full_ref: \"#Reference\u003c0.0.575235.90977385.2484928515.120170\u003e\",\n    ml_name: \"1110df0436fd62b85bb8579695102f38ff034092-m512.webp\",\n    thumb_name: \"1110df0436fd62b85bb8579695102f38ff034092-m200.webp\",\n    full_name: \"1110df0436fd62b85bb8579695102f38ff034092-m1440.webp\"\n  },\n  %{\n    base: \"dff3a13e58c2fcb2d1382f8f016e035405bad359\",\n    pred_ref: \"#Reference\u003c0.0.575235.90977385.2484928518.78487\u003e\",\n    thumb_ref: \"#Reference\u003c0.0.575235.90977385.2484928518.78510\u003e\",\n    full_ref: \"#Reference\u003c0.0.575235.90977385.2484928518.78414\u003e\",\n    ml_name: \"dff3a13e58c2fcb2d1382f8f016e035405bad359-m512.webp\",\n    thumb_name: \"dff3a13e58c2fcb2d1382f8f016e035405bad359-m200.webp\",\n    full_name: \"dff3a13e58c2fcb2d1382f8f016e035405bad359-m1440.webp\"\n  }\n]\n\n[\n  %{\n    full: \"https://843179836f19f3543d8ed2866db92b5f.r2.cloudflarestorage.com/up-image/dff3a13e58c2fcb2d1382f8f016e035405bad359-m1440.webp\",\n    base: \"dff3a13e58c2fcb2d1382f8f016e035405bad359\",\n    thumb: \"https://843179836f19f3543d8ed2866db92b5f.r2.cloudflarestorage.com/up-image/dff3a13e58c2fcb2d1382f8f016e035405bad359-m200.webp\",\n    pred_ref: \"#Reference\u003c0.0.698115.90977385.2484928517.100235\u003e\",\n    thumb_ref: \"#Reference\u003c0.0.698115.90977385.2484928517.100257\u003e\",\n    full_ref: \"#Reference\u003c0.0.698115.90977385.2484928517.100172\u003e\",\n    ml_name: \"dff3a13e58c2fcb2d1382f8f016e035405bad359-m512.webp\",\n    thumb_name: \"dff3a13e58c2fcb2d1382f8f016e035405bad359-m200.webp\",\n    full_name: \"dff3a13e58c2fcb2d1382f8f016e035405bad359-m1440.webp\"\n    },\n   %{\n    full: \"https://843179836f19f3543d8ed2866db92b5f.r2.cloudflarestorage.com/up-image/dff3a13e58c2fcb2d1382f8f016e035405bad359-m1440.webp\",\n    base: \"dff3a13e58c2fcb2d1382f8f016e035405bad359\",\n    thumb: \"https://843179836f19f3543d8ed2866db92b5f.r2.cloudflarestorage.com/up-image/dff3a13e58c2fcb2d1382f8f016e035405bad359-m200.webp\",\n    pred_ref: \"#Reference\u003c0.0.698115.90977385.2484928517.100235\u003e\",\n    thumb_ref: \"#Reference\u003c0.0.698115.90977385.2484928517.100257\u003e\",\n    full_ref: \"#Reference\u003c0.0.698115.90977385.2484928517.100172\u003e\",\n    ml_name: \"dff3a13e58c2fcb2d1382f8f016e035405bad359-m512.webp\",\n    thumb_name: \"dff3a13e58c2fcb2d1382f8f016e035405bad359-m200.webp\",\n    full_name: \"dff3a13e58c2fcb2d1382f8f016e035405bad359-m1440.webp\"\n  }\n]\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fndrean%2Fupimage","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fndrean%2Fupimage","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fndrean%2Fupimage/lists"}