From 776bb462a4131eff64b21f1f4007b41a419d8df6 Mon Sep 17 00:00:00 2001 From: RicoPleasure Date: Wed, 30 Jul 2025 16:50:46 +0100 Subject: [PATCH 1/6] feat: add deps and base structure for avatars --- config/config.exs | 6 +++++ config/prod.exs | 20 +++++++++++++++ lib/atlas/uploader.ex | 15 ++++++++++++ lib/atlas/uploaders/user_avatar.ex | 39 ++++++++++++++++++++++++++++++ mix.exs | 8 ++++++ mix.lock | 16 +++++++++++- 6 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 lib/atlas/uploader.ex create mode 100644 lib/atlas/uploaders/user_avatar.ex diff --git a/config/config.exs b/config/config.exs index e57137c..6f69146 100644 --- a/config/config.exs +++ b/config/config.exs @@ -36,6 +36,12 @@ config :logger, :console, format: "$time $metadata[$level] $message\n", metadata: [:request_id] +# Configures Waffle +config :waffle, + storage: Waffle.Storage.Local, + storage_dir_prefix: "priv", + asset_host: {:system, "ASSET_HOST"} + # Use Jason for JSON parsing in Phoenix config :phoenix, :json_library, Jason diff --git a/config/prod.exs b/config/prod.exs index 1d0d4e6..451543f 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -9,5 +9,25 @@ config :swoosh, local: false # Do not print debug messages in production config :logger, level: :info +# Configures Waffle +config :waffle, + storage: Waffle.Storage.S3, + bucket: {:system, "AWS_S3_BUCKET"}, + asset_host: {:system, "ASSET_HOST"} + +# Configure ExAws +config :ex_aws, + json_codec: Jason, + access_key_id: {:system, "AWS_ACCESS_KEY_ID"}, + secret_access_key: {:system, "AWS_SECRET_ACCESS_KEY"}, + region: {:system, "AWS_REGION"}, + s3: [ + scheme: "https://", + host: {:system, "ASSET_HOST"}, + region: {:system, "AWS_REGION"}, + access_key_id: {:system, "AWS_ACCESS_KEY_ID"}, + secret_access_key: {:system, "AWS_SECRET_ACCESS_KEY"} + ] + # Runtime production configuration, including reading # of environment variables, is done on config/runtime.exs. diff --git a/lib/atlas/uploader.ex b/lib/atlas/uploader.ex new file mode 100644 index 0000000..65f9faf --- /dev/null +++ b/lib/atlas/uploader.ex @@ -0,0 +1,15 @@ +defmodule Atlas.Uploader do + @moduledoc """ + Base uploader module. + """ + defmacro __using__(_) do + quote do + use Waffle.Definition + use Waffle.Ecto.Definition + + def s3_object_headers(_version, {file, _scope}) do + [content_type: MIME.from_path(file.file_name)] + end + end + end +end diff --git a/lib/atlas/uploaders/user_avatar.ex b/lib/atlas/uploaders/user_avatar.ex new file mode 100644 index 0000000..76d5292 --- /dev/null +++ b/lib/atlas/uploaders/user_avatar.ex @@ -0,0 +1,39 @@ +defmodule Atlas.Uploaders.UserAvatar do + @moduledoc """ + User Avatar image uploader. + """ + use Atlas.Uploader + + alias Atlas.Accounts.User + + @versions [:original] + @extension_whitelist ~w(.jpg .jpeg .png) + + + def validate({file, _}) do + file_extension = file.file_name |> Path.extname() |> String.downcase() + + case Enum.member?(extension_whitelist(), file_extension) do + true -> :ok + false -> {:error, "Invalid file type"} + end + end + + def storage_dir(_, {_file, %User{} = user}) do + "uploads/user/avatars/#{user.id}" + end + + def filename(version, _) do + version + end + + def extension_whitelist do + @extension_whitelist + end + + # Provide a default URL if there hasn't been a file uploaded + # def default_url(version, scope) do + # "/images/avatars/default_#{version}.png" + # end + +end diff --git a/mix.exs b/mix.exs index 326a3ec..8e19d49 100644 --- a/mix.exs +++ b/mix.exs @@ -55,6 +55,14 @@ defmodule Atlas.MixProject do # tools {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, + # uploads + {:waffle, "~> 1.1"}, + {:waffle_ecto, "~> 0.0.12"}, + {:ex_aws, "~> 2.1.2"}, + {:ex_aws_s3, "~> 2.0"}, + {:hackney, "~> 1.9"}, + {:sweet_xml, "~> 0.6"}, + # monitoring {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, diff --git a/mix.lock b/mix.lock index a1cbc8c..3692628 100644 --- a/mix.lock +++ b/mix.lock @@ -3,9 +3,10 @@ "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"}, - "corsica": {:hex, :corsica, "2.1.3", "dccd094ffce38178acead9ae743180cdaffa388f35f0461ba1e8151d32e190e6", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "616c08f61a345780c2cf662ff226816f04d8868e12054e68963e95285b5be8bc"}, + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, + "corsica": {:hex, :corsica, "2.1.3", "dccd094ffce38178acead9ae743180cdaffa388f35f0461ba1e8151d32e190e6", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "616c08f61a345780c2cf662ff226816f04d8868e12054e68963e95285b5be8bc"}, "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, "db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, @@ -13,17 +14,24 @@ "ecto": {:hex, :ecto, "3.13.2", "7d0c0863f3fc8d71d17fc3ad3b9424beae13f02712ad84191a826c7169484f01", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "669d9291370513ff56e7b7e7081b7af3283d02e046cf3d403053c557894a0b3e"}, "ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, + "ex_aws": {:hex, :ex_aws, "2.1.9", "dc4865ecc20a05190a34a0ac5213e3e5e2b0a75a0c2835e923ae7bfeac5e3c31", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "3e6c776703c9076001fbe1f7c049535f042cb2afa0d2cbd3b47cbc4e92ac0d10"}, + "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.7", "e571424d2f345299753382f3a01b005c422b1a460a8bc3ed47659b3d3ef91e9e", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "858e51241e50181e29aa2bc128fef548873a3a9cd580471f57eda5b64dec937f"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, "guardian": {:hex, :guardian, "2.3.2", "78003504b987f2b189d76ccf9496ceaa6a454bb2763627702233f31eb7212881", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "b189ff38cd46a22a8a824866a6867ca8722942347f13c33f7d23126af8821b52"}, "guardian_db": {:hex, :guardian_db, "3.0.0", "c42902e3f1af1ba1e2d0c10913b926a1421f3a7e38eb4fc382b715c17489abdb", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:guardian, "~> 1.0 or ~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "9c2ec4278efa34f9f1cc6ba795e552d41fdc7ffba5319d67eeb533b89392d183"}, + "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"}, "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, @@ -33,14 +41,20 @@ "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, "remote_ip": {:hex, :remote_ip, "1.2.0", "fb078e12a44414f4cef5a75963c33008fe169b806572ccd17257c208a7bc760f", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2ff91de19c48149ce19ed230a81d377186e4412552a597d6a5137373e5877cb7"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "swoosh": {:hex, :swoosh, "1.19.3", "02ad4455939f502386e4e1443d4de94c514995fd0e51b3cafffd6bd270ffe81c", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "04a10f8496786b744b84130e3510eb53ca51e769c39511b65023bdf4136b732f"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.2.0", "ba82e333215aed9dd2096f93bd1d13ae89d249f82760fcada0850ba33bac154b", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7216e21a6c326eb9aa44328028c34e9fd348fb53667ca837be59d0aa2a0156e8"}, "thousand_island": {:hex, :thousand_island, "1.3.14", "ad45ebed2577b5437582bcc79c5eccd1e2a8c326abf6a3464ab6c06e2055a34a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d24a929d31cdd1d7903a4fe7f2409afeedff092d277be604966cd6aa4307ef"}, "ua_parser": {:hex, :ua_parser, "1.9.3", "1c3191ac62a6f3663b9c213ae5e1faef5dc03e29b6edbe34731a8f2f07802467", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "17e1b46cee8c2b49a4f9edec7ecb822846d4974cbd84ce02cbc169cdf1f58dfb"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, + "waffle": {:hex, :waffle, "1.1.9", "8ce5ca9e59fa5491da67a2df57b8711d93223df3c3e5c21ad2acdedc41a0f51a", [:mix], [{:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:ex_aws_s3, "~> 2.1", [hex: :ex_aws_s3, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "307c63cfdfb4624e7c423868a128ccfcb0e5291ae73a9deecb3a10b7a3eb277c"}, + "waffle_ecto": {:hex, :waffle_ecto, "0.0.12", "e5c17c49b071b903df71861c642093281123142dc4e9908c930db3e06795b040", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:waffle, "~> 1.0", [hex: :waffle, repo: "hexpm", optional: false]}], "hexpm", "585fe6371057066d2e8e3383ddd7a2437ff0668caf3f4cbf5a041e0de9837168"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, From 8f7ded0a089b07d44f6c9c83d58bd5aabda3c1ce Mon Sep 17 00:00:00 2001 From: RicoPleasure Date: Mon, 11 Aug 2025 12:32:46 +0100 Subject: [PATCH 2/6] feat: add migration and some functions --- lib/atlas/accounts.ex | 22 ++++++++ lib/atlas/accounts/user.ex | 11 ++++ lib/atlas/uploaders/user_avatar.ex | 6 +-- lib/atlas_web/controllers/user_controller.ex | 51 +++++++++++++++++++ lib/atlas_web/router.ex | 3 ++ .../20250810133546_add_avatar_to_users.exs | 9 ++++ 6 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 lib/atlas_web/controllers/user_controller.ex create mode 100644 priv/repo/migrations/20250810133546_add_avatar_to_users.exs diff --git a/lib/atlas/accounts.ex b/lib/atlas/accounts.ex index 38a05f2..62fefac 100644 --- a/lib/atlas/accounts.ex +++ b/lib/atlas/accounts.ex @@ -488,4 +488,26 @@ defmodule Atlas.Accounts do Guardian.DB.revoke_all(user_session.id) Repo.delete(user_session) end + + @doc """ + Updates an user's avatar. + """ + + def update_user_avatar(%User{} = user, attrs) do + user + |> User.avatar_changeset(attrs) + |> Repo.update() + end + + @doc """ + Gets the avatar url. + + ## Examples + ... (not tested yet) + """ + + def get_user_avatar_url(%User{} = user) do + Atlas.Uploaders.UserAvatar.url({user.avatar, user}) + end + end diff --git a/lib/atlas/accounts/user.ex b/lib/atlas/accounts/user.ex index 510bbc2..9c252b9 100644 --- a/lib/atlas/accounts/user.ex +++ b/lib/atlas/accounts/user.ex @@ -3,6 +3,7 @@ defmodule Atlas.Accounts.User do Application user schema and changesets. """ use Atlas.Schema + use Waffle.Ecto.Schema schema "users" do field :name, :string @@ -12,6 +13,7 @@ defmodule Atlas.Accounts.User do field :current_password, :string, virtual: true, redact: true field :confirmed_at, :utc_datetime field :type, Ecto.Enum, values: [:student, :admin, :professor] + field :avatar, Atlas.Uploaders.UserAvatar.Type timestamps(type: :utc_datetime) end @@ -163,4 +165,13 @@ defmodule Atlas.Accounts.User do add_error(changeset, :current_password, "is not valid") end end + + @doc """ + A user changeset for updating avatar + """ + def avatar_changeset(user, attrs) do + user + |> cast_attachments(attrs, [:avatar]) + end + end diff --git a/lib/atlas/uploaders/user_avatar.ex b/lib/atlas/uploaders/user_avatar.ex index 76d5292..9f0bf5e 100644 --- a/lib/atlas/uploaders/user_avatar.ex +++ b/lib/atlas/uploaders/user_avatar.ex @@ -4,8 +4,6 @@ defmodule Atlas.Uploaders.UserAvatar do """ use Atlas.Uploader - alias Atlas.Accounts.User - @versions [:original] @extension_whitelist ~w(.jpg .jpeg .png) @@ -19,8 +17,8 @@ defmodule Atlas.Uploaders.UserAvatar do end end - def storage_dir(_, {_file, %User{} = user}) do - "uploads/user/avatars/#{user.id}" + def storage_dir(_version, {_file, %{id: user_id}}) do + "uploads/user/avatars/#{user_id}" end def filename(version, _) do diff --git a/lib/atlas_web/controllers/user_controller.ex b/lib/atlas_web/controllers/user_controller.ex new file mode 100644 index 0000000..8d2f46f --- /dev/null +++ b/lib/atlas_web/controllers/user_controller.ex @@ -0,0 +1,51 @@ +defmodule AtlasWeb.UserController do + use AtlasWeb, :controller + + alias Atlas.Accounts + + def upload_avatar(conn, %{"id" => user_id, "avatar" => upload}) do + user_id + |> get_user() + |> update_user_avatar(upload) + |> send_response(conn) + end + + def upload_avatar(conn, %{"id" => _user_id}) do + conn + |> put_status(:unprocessable_entity) + |> json(%{status: "error", message: "No avatar file provided"}) + end + + defp get_user(user_id) do + case Accounts.get_user(user_id) do + %Atlas.Accounts.User{} = user -> {:ok, user} + nil -> {:error, :not_found} + end + end + + defp update_user_avatar({:ok, user}, upload) do + Accounts.update_user_avatar(user, %{avatar: upload}) + end + + defp update_user_avatar(error, _upload), do: error + + defp send_response({:ok, user}, conn) do + conn + |> put_status(:ok) + |> json(%{ + status: "success", + message: "Avatar uploaded successfully", + data: %{avatar_url: Accounts.get_user_avatar_url(user), user_id: user.id} + }) + end + + defp send_response({:error, reason}, conn) do + {status, message} = case reason do + :not_found -> {:not_found, "User not found"} + %Ecto.Changeset{} -> {:unprocessable_entity, "Avatar validation failed"} + _ -> {:unprocessable_entity, "Upload failed"} + end + + conn |> put_status(status) |> json(%{status: "error", message: message}) + end +end diff --git a/lib/atlas_web/router.ex b/lib/atlas_web/router.ex index b9fd3e9..9ea137a 100644 --- a/lib/atlas_web/router.ex +++ b/lib/atlas_web/router.ex @@ -39,6 +39,9 @@ defmodule AtlasWeb.Router do pipe_through :auth + post "/users/:id/avatar", UserController, :upload_avatar + delete "/users/:id/avatar", UserController, :delete_avatar + scope "/auth" do post "/sign_out", AuthController, :sign_out get "/me", AuthController, :me diff --git a/priv/repo/migrations/20250810133546_add_avatar_to_users.exs b/priv/repo/migrations/20250810133546_add_avatar_to_users.exs new file mode 100644 index 0000000..92d58d4 --- /dev/null +++ b/priv/repo/migrations/20250810133546_add_avatar_to_users.exs @@ -0,0 +1,9 @@ +defmodule Atlas.Repo.Migrations.AddAvatarToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :avatar, :string + end + end +end From 3efc7c8b5faa989474899444842041bc9cb84b17 Mon Sep 17 00:00:00 2001 From: RicoPleasure Date: Sat, 16 Aug 2025 18:13:49 +0100 Subject: [PATCH 3/6] chore: tests for avatar upload --- priv/static/swagger.json | 14 ++++---- test/atlas/uploaders/avatar_test.exs | 43 ++++++++++++++++++++++++ test/support/fixtures/images/avatar.png | Bin 0 -> 26326 bytes 3 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 test/atlas/uploaders/avatar_test.exs create mode 100644 test/support/fixtures/images/avatar.png diff --git a/priv/static/swagger.json b/priv/static/swagger.json index 0a3febc..971693e 100644 --- a/priv/static/swagger.json +++ b/priv/static/swagger.json @@ -332,6 +332,13 @@ "type": "object" } }, + "securityDefinitions": { + "Bearer": { + "in": "header", + "name": "Authorization", + "type": "apiKey" + } + }, "paths": { "/v1/auth/forgot_password": { "post": { @@ -699,12 +706,5 @@ } } }, - "securityDefinitions": { - "Bearer": { - "in": "header", - "name": "Authorization", - "type": "apiKey" - } - }, "swagger": "2.0" } \ No newline at end of file diff --git a/test/atlas/uploaders/avatar_test.exs b/test/atlas/uploaders/avatar_test.exs new file mode 100644 index 0000000..7a7823d --- /dev/null +++ b/test/atlas/uploaders/avatar_test.exs @@ -0,0 +1,43 @@ +defmodule Atlas.AvatarTest do + use AtlasWeb.ConnCase + + alias AtlasWeb.UserController + alias Atlas.AccountsFixtures + + setup do + user = AccountsFixtures.user_fixture(%{type: :student}) + conn = authenticated_conn(%{type: :student}) + %{ + user: user, + conn: conn + } + end + + describe "valid avatar upload" do + test "uploads a valid avatar", %{user: user, conn: conn} do + upload = %Plug.Upload{ + content_type: "image/png", + filename: "avatar.png", + path: "test/support/fixtures/images/avatar.png" + } + + conn = UserController.upload_avatar(conn, %{"id" => user.id, "avatar" => upload}) + assert conn.status == 200 + assert %{"status" => "success", "message" => "Avatar uploaded successfully"} = Jason.decode!(conn.resp_body) + end + end + + describe "invalid avatar upload" do + test "uploads an invalid avatar", %{user: user, conn: conn} do + upload = %Plug.Upload{ + content_type: "image/gif", + filename: "avatar.gif", + path: "test/support/fixtures/images/avatar.gif" + } + + conn = UserController.upload_avatar(conn, %{"id" => user.id, "avatar" => upload}) + assert conn.status == 422 + assert %{"status" => "error", "message" => "Avatar validation failed"} = Jason.decode!(conn.resp_body) + end + end +end diff --git a/test/support/fixtures/images/avatar.png b/test/support/fixtures/images/avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..c8964cb67c981341996ee7cfa51a9a96277e8d27 GIT binary patch literal 26326 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 Gg#8cCpNFpi literal 0 HcmV?d00001 From 8458f0a95da2b4a520c3f8144aa4abd8b8f17763 Mon Sep 17 00:00:00 2001 From: RicoPleasure Date: Sat, 16 Aug 2025 18:55:57 +0100 Subject: [PATCH 4/6] chore: add swagger for avatar upload --- lib/atlas_web/controllers/user_controller.ex | 55 +++++++++ priv/static/swagger.json | 112 ++++++++++++++++++- 2 files changed, 163 insertions(+), 4 deletions(-) diff --git a/lib/atlas_web/controllers/user_controller.ex b/lib/atlas_web/controllers/user_controller.ex index 8d2f46f..874ca47 100644 --- a/lib/atlas_web/controllers/user_controller.ex +++ b/lib/atlas_web/controllers/user_controller.ex @@ -1,5 +1,6 @@ defmodule AtlasWeb.UserController do use AtlasWeb, :controller + use PhoenixSwagger alias Atlas.Accounts @@ -16,6 +17,24 @@ defmodule AtlasWeb.UserController do |> json(%{status: "error", message: "No avatar file provided"}) end + swagger_path :upload_avatar do + post("/v1/users/{id}/avatar") + summary("Upload user avatar") + description("Upload an avatar image for a specific user") + produces("application/json") + tag("Uploaders") + consumes("multipart/form-data") + security([%{Bearer: []}]) + + parameters do + id(:path, :string, "User ID", required: true) + avatar(:formData, :file, "Avatar image file", required: true) + end + + response 200, "Success", Schema.ref(:AvatarUploadSuccess) + response 422, "Validation error", Schema.ref(:ErrorResponse) + end + defp get_user(user_id) do case Accounts.get_user(user_id) do %Atlas.Accounts.User{} = user -> {:ok, user} @@ -48,4 +67,40 @@ defmodule AtlasWeb.UserController do conn |> put_status(status) |> json(%{status: "error", message: message}) end + + def swagger_definitions do + %{ + AvatarUploadSuccess: swagger_schema do + title("Avatar Upload Success Response") + description("Successful avatar upload response") + type(:object) + properties do + status(:string, "Response status", example: "success") + message(:string, "Success message", example: "Avatar uploaded successfully") + data(Schema.ref(:AvatarData)) + end + required([:status, :message, :data]) + end, + AvatarData: swagger_schema do + title("Avatar Data") + description("Avatar upload data") + type(:object) + properties do + avatar_url(:string, "URL of the uploaded avatar", example: "/this/is/an/xXxXxXxX-xxxx-xxxx-xxxx-xxxxxxxxxxxx/example.jpg") + user_id(:string, "User UUID", example: "xXxXxXxX-xxxx-xxxx-xxxx-xxxxxxxxxxxx") + end + required([:avatar_url, :user_id]) + end, + ErrorResponse: swagger_schema do + title("Error Response") + description("Error response format") + type(:object) + properties do + status(:string, "Response status", example: "error") + message(:string, "Error message", example: "User not found") + end + required([:status, :message]) + end + } + end end diff --git a/priv/static/swagger.json b/priv/static/swagger.json index 971693e..a902990 100644 --- a/priv/static/swagger.json +++ b/priv/static/swagger.json @@ -179,17 +179,24 @@ "type": "object" }, "ErrorResponse": { - "description": "Error response schema", + "description": "Error response format", "properties": { - "error": { + "message": { "description": "Error message", + "example": "User not found", + "type": "string" + }, + "status": { + "description": "Response status", + "example": "error", "type": "string" } }, "required": [ - "error" + "status", + "message" ], - "title": "ErrorResponse", + "title": "Error Response", "type": "object" }, "SuccessfulRefreshResponse": { @@ -330,6 +337,52 @@ }, "title": "JobResponse", "type": "object" + }, + "AvatarUploadSuccess": { + "description": "Successful avatar upload response", + "properties": { + "data": { + "$ref": "#/definitions/AvatarData" + }, + "message": { + "description": "Success message", + "example": "Avatar uploaded successfully", + "type": "string" + }, + "status": { + "description": "Response status", + "example": "success", + "type": "string" + } + }, + "required": [ + "status", + "message", + "data" + ], + "title": "Avatar Upload Success Response", + "type": "object" + }, + "AvatarData": { + "description": "Avatar upload data", + "properties": { + "avatar_url": { + "description": "URL of the uploaded avatar", + "example": "/this/is/an/xXxXxXxX-xxxx-xxxx-xxxx-xxxxxxxxxxxx/example.jpg", + "type": "string" + }, + "user_id": { + "description": "User UUID", + "example": "xXxXxXxX-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "type": "string" + } + }, + "required": [ + "avatar_url", + "user_id" + ], + "title": "Avatar Data", + "type": "object" } }, "securityDefinitions": { @@ -704,6 +757,57 @@ "Job" ] } + }, + "/v1/users/{id}/avatar": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "description": "Upload an avatar image for a specific user", + "operationId": "AtlasWeb.UserController.upload_avatar", + "parameters": [ + { + "description": "User ID", + "in": "path", + "name": "id", + "required": true, + "type": "string" + }, + { + "description": "Avatar image file", + "in": "formData", + "name": "avatar", + "required": true, + "type": "file" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/AvatarUploadSuccess" + } + }, + "422": { + "description": "Validation error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "security": [ + { + "Bearer": [] + } + ], + "summary": "Upload user avatar", + "tags": [ + "Uploaders" + ] + } } }, "swagger": "2.0" From 1380ef920818ef2f2b3df980e2211e10845741ff Mon Sep 17 00:00:00 2001 From: RicoPleasure Date: Sat, 16 Aug 2025 19:35:42 +0100 Subject: [PATCH 5/6] chore: add delete avatar functions and swagger --- lib/atlas/accounts.ex | 13 ++ lib/atlas/accounts/user.ex | 1 - lib/atlas/uploaders/user_avatar.ex | 2 - lib/atlas_web/controllers/user_controller.ex | 172 ++++++++++++++----- priv/static/swagger.json | 79 +++++++++ test/atlas/uploaders/avatar_test.exs | 9 +- 6 files changed, 229 insertions(+), 47 deletions(-) diff --git a/lib/atlas/accounts.ex b/lib/atlas/accounts.ex index f72544b..cd892ff 100644 --- a/lib/atlas/accounts.ex +++ b/lib/atlas/accounts.ex @@ -621,4 +621,17 @@ defmodule Atlas.Accounts do Atlas.Uploaders.UserAvatar.url({user.avatar, user}) end + @doc """ + Deletes a user's avatar. + """ + + def remove_user_avatar(%User{} = user) do + if user.avatar do + Atlas.Uploaders.UserAvatar.delete({user.avatar, user}) + end + + user + |> User.avatar_changeset(%{avatar: nil}) + |> Repo.update() + end end diff --git a/lib/atlas/accounts/user.ex b/lib/atlas/accounts/user.ex index b388834..b9ae89d 100644 --- a/lib/atlas/accounts/user.ex +++ b/lib/atlas/accounts/user.ex @@ -177,5 +177,4 @@ defmodule Atlas.Accounts.User do user |> cast_attachments(attrs, [:avatar]) end - end diff --git a/lib/atlas/uploaders/user_avatar.ex b/lib/atlas/uploaders/user_avatar.ex index 9f0bf5e..b067f37 100644 --- a/lib/atlas/uploaders/user_avatar.ex +++ b/lib/atlas/uploaders/user_avatar.ex @@ -7,7 +7,6 @@ defmodule Atlas.Uploaders.UserAvatar do @versions [:original] @extension_whitelist ~w(.jpg .jpeg .png) - def validate({file, _}) do file_extension = file.file_name |> Path.extname() |> String.downcase() @@ -33,5 +32,4 @@ defmodule Atlas.Uploaders.UserAvatar do # def default_url(version, scope) do # "/images/avatars/default_#{version}.png" # end - end diff --git a/lib/atlas_web/controllers/user_controller.ex b/lib/atlas_web/controllers/user_controller.ex index 874ca47..22ad2be 100644 --- a/lib/atlas_web/controllers/user_controller.ex +++ b/lib/atlas_web/controllers/user_controller.ex @@ -8,7 +8,7 @@ defmodule AtlasWeb.UserController do user_id |> get_user() |> update_user_avatar(upload) - |> send_response(conn) + |> send_upload_response(conn) end def upload_avatar(conn, %{"id" => _user_id}) do @@ -17,6 +17,27 @@ defmodule AtlasWeb.UserController do |> json(%{status: "error", message: "No avatar file provided"}) end + defp send_upload_response({:ok, user}, conn) do + conn + |> put_status(:ok) + |> json(%{ + status: "success", + message: "Avatar uploaded successfully", + data: %{avatar_url: Accounts.get_user_avatar_url(user), user_id: user.id} + }) + end + + defp send_upload_response({:error, reason}, conn) do + {status, message} = + case reason do + :not_found -> {:not_found, "User not found"} + %Ecto.Changeset{} -> {:unprocessable_entity, "Avatar validation failed"} + _ -> {:unprocessable_entity, "Upload failed"} + end + + conn |> put_status(status) |> json(%{status: "error", message: message}) + end + swagger_path :upload_avatar do post("/v1/users/{id}/avatar") summary("Upload user avatar") @@ -31,8 +52,8 @@ defmodule AtlasWeb.UserController do avatar(:formData, :file, "Avatar image file", required: true) end - response 200, "Success", Schema.ref(:AvatarUploadSuccess) - response 422, "Validation error", Schema.ref(:ErrorResponse) + response(200, "Success", Schema.ref(:AvatarUploadSuccess)) + response(422, "Validation error", Schema.ref(:ErrorResponse)) end defp get_user(user_id) do @@ -48,59 +69,126 @@ defmodule AtlasWeb.UserController do defp update_user_avatar(error, _upload), do: error - defp send_response({:ok, user}, conn) do + def delete_avatar(conn, %{"id" => user_id}) do + user_id + |> get_user() + |> remove_user_avatar() + |> send_delete_response(conn) + end + + defp remove_user_avatar({:ok, user}) do + Accounts.remove_user_avatar(user) + end + + defp remove_user_avatar(error), do: error + + defp send_delete_response({:ok, user}, conn) do conn |> put_status(:ok) |> json(%{ status: "success", - message: "Avatar uploaded successfully", - data: %{avatar_url: Accounts.get_user_avatar_url(user), user_id: user.id} + message: "Avatar deleted successfully", + data: %{user_id: user.id} }) end - defp send_response({:error, reason}, conn) do - {status, message} = case reason do - :not_found -> {:not_found, "User not found"} - %Ecto.Changeset{} -> {:unprocessable_entity, "Avatar validation failed"} - _ -> {:unprocessable_entity, "Upload failed"} - end + defp send_delete_response({:error, reason}, conn) do + {status, message} = + case reason do + %Ecto.Changeset{} -> {:unprocessable_entity, "Avatar deletion failed"} + _ -> {:unprocessable_entity, "Deletion failed"} + end conn |> put_status(status) |> json(%{status: "error", message: message}) end + swagger_path :delete_avatar do + delete("/v1/users/{id}/avatar") + summary("Delete user avatar") + description("Delete the avatar image for a specific user") + produces("application/json") + tag("Uploaders") + security([%{Bearer: []}]) + + parameters do + id(:path, :string, "User ID", required: true) + end + + response(200, "Success", Schema.ref(:AvatarDeleteSuccess)) + response(422, "Deletion failed", Schema.ref(:ErrorResponse)) + end + def swagger_definitions do %{ - AvatarUploadSuccess: swagger_schema do - title("Avatar Upload Success Response") - description("Successful avatar upload response") - type(:object) - properties do - status(:string, "Response status", example: "success") - message(:string, "Success message", example: "Avatar uploaded successfully") - data(Schema.ref(:AvatarData)) - end - required([:status, :message, :data]) - end, - AvatarData: swagger_schema do - title("Avatar Data") - description("Avatar upload data") - type(:object) - properties do - avatar_url(:string, "URL of the uploaded avatar", example: "/this/is/an/xXxXxXxX-xxxx-xxxx-xxxx-xxxxxxxxxxxx/example.jpg") - user_id(:string, "User UUID", example: "xXxXxXxX-xxxx-xxxx-xxxx-xxxxxxxxxxxx") - end - required([:avatar_url, :user_id]) - end, - ErrorResponse: swagger_schema do - title("Error Response") - description("Error response format") - type(:object) - properties do - status(:string, "Response status", example: "error") - message(:string, "Error message", example: "User not found") + AvatarUploadSuccess: + swagger_schema do + title("Avatar Upload Success Response") + description("Successful avatar upload response") + type(:object) + + properties do + status(:string, "Response status", example: "success") + message(:string, "Success message", example: "Avatar uploaded successfully") + data(Schema.ref(:AvatarData)) + end + + required([:status, :message, :data]) + end, + AvatarData: + swagger_schema do + title("Avatar Data") + description("Avatar upload data") + type(:object) + + properties do + avatar_url(:string, "URL of the uploaded avatar", + example: "/this/is/an/xXxXxXxX-xxxx-xxxx-xxxx-xxxxxxxxxxxx/example.jpg" + ) + + user_id(:string, "User UUID", example: "xXxXxXxX-xxxx-xxxx-xxxx-xxxxxxxxxxxx") + end + + required([:avatar_url, :user_id]) + end, + ErrorResponse: + swagger_schema do + title("Error Response") + description("Error response format") + type(:object) + + properties do + status(:string, "Response status", example: "error") + message(:string, "Error message", example: "User not found") + end + + required([:status, :message]) + end, + AvatarDeleteSuccess: + swagger_schema do + title("Successful Avatar Deletion") + description("Successful avatar deletion response") + type(:object) + + properties do + status(:string, "Response status", example: "success") + message(:string, "Success message", example: "Avatar deleted successfully") + data(Schema.ref(:DeleteData)) + end + + required([:status, :message, :data]) + end, + DeleteData: + swagger_schema do + title("Delete Data") + description("Avatar deletion data") + type(:object) + + properties do + user_id(:string, "User UUID", example: "xXxXxXxX-xxxx-xxxx-xxxx-xxxxxxxxxxxx") + end + + required([:user_id]) end - required([:status, :message]) - end } end end diff --git a/priv/static/swagger.json b/priv/static/swagger.json index a902990..4b94dca 100644 --- a/priv/static/swagger.json +++ b/priv/static/swagger.json @@ -383,6 +383,46 @@ ], "title": "Avatar Data", "type": "object" + }, + "AvatarDeleteSuccess": { + "description": "Successful avatar deletion response", + "properties": { + "data": { + "$ref": "#/definitions/DeleteData" + }, + "message": { + "description": "Success message", + "example": "Avatar deleted successfully", + "type": "string" + }, + "status": { + "description": "Response status", + "example": "success", + "type": "string" + } + }, + "required": [ + "status", + "message", + "data" + ], + "title": "Successful Avatar Deletion", + "type": "object" + }, + "DeleteData": { + "description": "Avatar deletion data", + "properties": { + "user_id": { + "description": "User UUID", + "example": "xXxXxXxX-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "type": "string" + } + }, + "required": [ + "user_id" + ], + "title": "Delete Data", + "type": "object" } }, "securityDefinitions": { @@ -759,6 +799,45 @@ } }, "/v1/users/{id}/avatar": { + "delete": { + "description": "Delete the avatar image for a specific user", + "operationId": "AtlasWeb.UserController.delete_avatar", + "parameters": [ + { + "description": "User ID", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "Success", + "schema": { + "$ref": "#/definitions/AvatarDeleteSuccess" + } + }, + "422": { + "description": "Deletion failed", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "security": [ + { + "Bearer": [] + } + ], + "summary": "Delete user avatar", + "tags": [ + "Uploaders" + ] + }, "post": { "consumes": [ "multipart/form-data" diff --git a/test/atlas/uploaders/avatar_test.exs b/test/atlas/uploaders/avatar_test.exs index 7a7823d..84fff00 100644 --- a/test/atlas/uploaders/avatar_test.exs +++ b/test/atlas/uploaders/avatar_test.exs @@ -7,6 +7,7 @@ defmodule Atlas.AvatarTest do setup do user = AccountsFixtures.user_fixture(%{type: :student}) conn = authenticated_conn(%{type: :student}) + %{ user: user, conn: conn @@ -23,7 +24,9 @@ defmodule Atlas.AvatarTest do conn = UserController.upload_avatar(conn, %{"id" => user.id, "avatar" => upload}) assert conn.status == 200 - assert %{"status" => "success", "message" => "Avatar uploaded successfully"} = Jason.decode!(conn.resp_body) + + assert %{"status" => "success", "message" => "Avatar uploaded successfully"} = + Jason.decode!(conn.resp_body) end end @@ -37,7 +40,9 @@ defmodule Atlas.AvatarTest do conn = UserController.upload_avatar(conn, %{"id" => user.id, "avatar" => upload}) assert conn.status == 422 - assert %{"status" => "error", "message" => "Avatar validation failed"} = Jason.decode!(conn.resp_body) + + assert %{"status" => "error", "message" => "Avatar validation failed"} = + Jason.decode!(conn.resp_body) end end end From 32d984419ec8ae6438f986a61987c269da03dfb8 Mon Sep 17 00:00:00 2001 From: RicoPleasure Date: Sat, 16 Aug 2025 19:38:25 +0100 Subject: [PATCH 6/6] fix: readability --- lib/atlas/accounts.ex | 5 +- priv/static/swagger.json | 316 +++++++++++++-------------- test/atlas/uploaders/avatar_test.exs | 2 +- 3 files changed, 162 insertions(+), 161 deletions(-) diff --git a/lib/atlas/accounts.ex b/lib/atlas/accounts.ex index cd892ff..d6196c7 100644 --- a/lib/atlas/accounts.ex +++ b/lib/atlas/accounts.ex @@ -7,6 +7,7 @@ defmodule Atlas.Accounts do alias Atlas.Accounts.{User, UserNotifier, UserPreference, UserSession, UserToken} alias Atlas.University.Student + alias Atlas.Uploaders.UserAvatar ## Database getters @@ -618,7 +619,7 @@ defmodule Atlas.Accounts do """ def get_user_avatar_url(%User{} = user) do - Atlas.Uploaders.UserAvatar.url({user.avatar, user}) + UserAvatar.url({user.avatar, user}) end @doc """ @@ -627,7 +628,7 @@ defmodule Atlas.Accounts do def remove_user_avatar(%User{} = user) do if user.avatar do - Atlas.Uploaders.UserAvatar.delete({user.avatar, user}) + UserAvatar.delete({user.avatar, user}) end user diff --git a/priv/static/swagger.json b/priv/static/swagger.json index 4b94dca..8f0546d 100644 --- a/priv/static/swagger.json +++ b/priv/static/swagger.json @@ -94,74 +94,21 @@ "title": "User Session", "type": "object" }, - "Job": { - "description": "A job in the system", - "properties": { - "attempted_at": { - "description": "Timestamp when the job was attempted", - "format": "date-time", - "type": "string" - }, - "completed_at": { - "description": "Timestamp when the job was completed", - "format": "date-time", - "type": "string" - }, - "id": { - "description": "ID of the job", - "type": "integer" - }, - "inserted_at": { - "description": "Timestamp when the job was created", - "format": "date-time", - "type": "string" - }, - "state": { - "description": "Status of the job", - "type": "string" - }, - "type": { - "description": "Type of the job", - "type": "string" - }, - "user_id": { - "description": "ID of the user who created the job", - "type": "string" - } - }, - "required": [ - "completed_at", - "attempted_at", - "inserted_at", - "user_id", - "state", - "type", - "id" - ], - "title": "Job", - "type": "object" - }, - "SignInResponse": { - "description": "Response schema for successful sign in", + "SignOutResponse": { + "description": "Response schema for successful sign out", "example": { - "access_token": "xXxXxXxXxXxX", - "session_id": "e1387cae-ac1d-4aeb-8e13-ff1b3dd15ca4" + "message": "Signed out successfully" }, "properties": { - "access_token": { - "description": "Access token", + "message": { + "description": "Message indicating successful sign out", "type": "string" - }, - "session_id": { - "description": "User session ID", - "type": "integer" } }, "required": [ - "access_token", - "session_id" + "message" ], - "title": "SignInResponse", + "title": "SignOutResponse", "type": "object" }, "UnauthorizedResponse": { @@ -199,38 +146,27 @@ "title": "Error Response", "type": "object" }, - "SuccessfulRefreshResponse": { - "description": "Response schema for successful token refresh", + "SignInResponse": { + "description": "Response schema for successful sign in", "example": { - "access_token": "xXxXxXxXxXxX" + "access_token": "xXxXxXxXxXxX", + "session_id": "e1387cae-ac1d-4aeb-8e13-ff1b3dd15ca4" }, "properties": { "access_token": { - "description": "New access token", - "type": "string" - } - }, - "required": [ - "access_token" - ], - "title": "SuccessfulRefreshResponse", - "type": "object" - }, - "SignOutResponse": { - "description": "Response schema for successful sign out", - "example": { - "message": "Signed out successfully" - }, - "properties": { - "message": { - "description": "Message indicating successful sign out", + "description": "Access token", "type": "string" + }, + "session_id": { + "description": "User session ID", + "type": "integer" } }, "required": [ - "message" + "access_token", + "session_id" ], - "title": "SignOutResponse", + "title": "SignInResponse", "type": "object" }, "UserSessionsResponse": { @@ -262,21 +198,6 @@ "title": "UserSessionsResponse", "type": "object" }, - "NoContentResponse": { - "description": "Response schema for no content", - "example": {}, - "properties": { - "message": { - "description": "Message indicating no content", - "type": "string" - } - }, - "required": [ - "message" - ], - "title": "NoContentResponse", - "type": "object" - }, "ResetPasswordResponse": { "description": "Response schema for successful password reset", "example": { @@ -294,48 +215,36 @@ "title": "ResetPasswordResponse", "type": "object" }, - "SuccessfulImport": { - "description": "Response for a successful import", + "SuccessfulRefreshResponse": { + "description": "Response schema for successful token refresh", + "example": { + "access_token": "xXxXxXxXxXxX" + }, "properties": { - "job_id": { - "description": "ID of the import job", - "type": "string" - }, - "message": { - "description": "Status message", + "access_token": { + "description": "New access token", "type": "string" } }, "required": [ - "message", - "job_id" + "access_token" ], - "title": "SuccessfulImport", - "type": "object" - }, - "JobsResponse": { - "description": "Response containing a list of jobs", - "properties": { - "jobs": { - "description": "List of jobs", - "items": { - "$ref": "#/definitions/Job" - }, - "type": "array" - } - }, - "title": "JobsResponse", + "title": "SuccessfulRefreshResponse", "type": "object" }, - "JobResponse": { - "description": "Response containing a single job", + "NoContentResponse": { + "description": "Response schema for no content", + "example": {}, "properties": { - "job": { - "$ref": "#/definitions/Job", - "description": "Details of the job" + "message": { + "description": "Message indicating no content", + "type": "string" } }, - "title": "JobResponse", + "required": [ + "message" + ], + "title": "NoContentResponse", "type": "object" }, "AvatarUploadSuccess": { @@ -363,27 +272,6 @@ "title": "Avatar Upload Success Response", "type": "object" }, - "AvatarData": { - "description": "Avatar upload data", - "properties": { - "avatar_url": { - "description": "URL of the uploaded avatar", - "example": "/this/is/an/xXxXxXxX-xxxx-xxxx-xxxx-xxxxxxxxxxxx/example.jpg", - "type": "string" - }, - "user_id": { - "description": "User UUID", - "example": "xXxXxXxX-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "type": "string" - } - }, - "required": [ - "avatar_url", - "user_id" - ], - "title": "Avatar Data", - "type": "object" - }, "AvatarDeleteSuccess": { "description": "Successful avatar deletion response", "properties": { @@ -409,6 +297,27 @@ "title": "Successful Avatar Deletion", "type": "object" }, + "AvatarData": { + "description": "Avatar upload data", + "properties": { + "avatar_url": { + "description": "URL of the uploaded avatar", + "example": "/this/is/an/xXxXxXxX-xxxx-xxxx-xxxx-xxxxxxxxxxxx/example.jpg", + "type": "string" + }, + "user_id": { + "description": "User UUID", + "example": "xXxXxXxX-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "type": "string" + } + }, + "required": [ + "avatar_url", + "user_id" + ], + "title": "Avatar Data", + "type": "object" + }, "DeleteData": { "description": "Avatar deletion data", "properties": { @@ -423,13 +332,97 @@ ], "title": "Delete Data", "type": "object" - } - }, - "securityDefinitions": { - "Bearer": { - "in": "header", - "name": "Authorization", - "type": "apiKey" + }, + "Job": { + "description": "A job in the system", + "properties": { + "attempted_at": { + "description": "Timestamp when the job was attempted", + "format": "date-time", + "type": "string" + }, + "completed_at": { + "description": "Timestamp when the job was completed", + "format": "date-time", + "type": "string" + }, + "id": { + "description": "ID of the job", + "type": "integer" + }, + "inserted_at": { + "description": "Timestamp when the job was created", + "format": "date-time", + "type": "string" + }, + "state": { + "description": "Status of the job", + "type": "string" + }, + "type": { + "description": "Type of the job", + "type": "string" + }, + "user_id": { + "description": "ID of the user who created the job", + "type": "string" + } + }, + "required": [ + "completed_at", + "attempted_at", + "inserted_at", + "user_id", + "state", + "type", + "id" + ], + "title": "Job", + "type": "object" + }, + "JobResponse": { + "description": "Response containing a single job", + "properties": { + "job": { + "$ref": "#/definitions/Job", + "description": "Details of the job" + } + }, + "title": "JobResponse", + "type": "object" + }, + "JobsResponse": { + "description": "Response containing a list of jobs", + "properties": { + "jobs": { + "description": "List of jobs", + "items": { + "$ref": "#/definitions/Job" + }, + "type": "array" + } + }, + "title": "JobsResponse", + "type": "object" + }, + "SuccessfulImport": { + "description": "Response for a successful import", + "properties": { + "job_id": { + "description": "ID of the import job", + "type": "string" + }, + "message": { + "description": "Status message", + "type": "string" + } + }, + "required": [ + "message", + "job_id" + ], + "title": "SuccessfulImport", + "type": "object" } }, "paths": { @@ -889,5 +882,12 @@ } } }, - "swagger": "2.0" + "swagger": "2.0", + "securityDefinitions": { + "Bearer": { + "in": "header", + "name": "Authorization", + "type": "apiKey" + } + } } \ No newline at end of file diff --git a/test/atlas/uploaders/avatar_test.exs b/test/atlas/uploaders/avatar_test.exs index 84fff00..d9a6fff 100644 --- a/test/atlas/uploaders/avatar_test.exs +++ b/test/atlas/uploaders/avatar_test.exs @@ -1,8 +1,8 @@ defmodule Atlas.AvatarTest do use AtlasWeb.ConnCase - alias AtlasWeb.UserController alias Atlas.AccountsFixtures + alias AtlasWeb.UserController setup do user = AccountsFixtures.user_fixture(%{type: :student})