diff --git a/.env.sample b/.env.sample index dc725c4..0f1d8df 100644 --- a/.env.sample +++ b/.env.sample @@ -1,3 +1,5 @@ +# === Basic Phoenix configuration === + BASE_URL=http://localhost:4000 # SAME_SITE_COOKIE=Lax # SECURE_COOKIE=false @@ -6,51 +8,75 @@ DATABASE_URL=postgres://claper:claper@db:5432/claper SECRET_KEY_BASE=0LZiQBLw4WvqPlz4cz8RsHJlxNiSqM9B48y4ChyJ5v1oA0L/TPIqRjQNdPZN3iEG # Generate with `mix phx.gen.secret` # ⚠️ Don't use this exact value for SECRET_KEY_BASE or someone would be able to sign a cookie with user_id=1 and log in as the admin! -# Storage configuration + +# === Storage configuration === PRESENTATION_STORAGE=local PRESENTATION_STORAGE_DIR=/app/uploads -#MAX_FILE_SIZE_MB=15 +# MAX_FILE_SIZE_MB=15 -# The standard AWS environment variables -#S3_ACCESS_KEY_ID=xxx -#S3_SECRET_ACCESS_KEY=xxx -#S3_REGION=eu-west-3 -#S3_BUCKET=xxx +# == Standard AWS environment variables -# If you're using an alternative S3-compatible service, port optional -#S3_SCHEME=https:// -#S3_HOST=www.example.com -#S3_PORT=443 +# S3_ACCESS_KEY_ID=xxx +# S3_SECRET_ACCESS_KEY=xxx +# S3_REGION=eu-west-3 +# S3_BUCKET=xxx -# If the public S3-compatible URL is different from the one used to write data -#S3_PUBLIC_URL=https://www.example.com +# == If you're using an alternative S3-compatible service, port optional -# Mail configuration +# S3_SCHEME=https:// +# S3_HOST=www.example.com +# S3_PORT=443 -MAIL_TRANSPORT=local +# == If the public S3-compatible URL is different from the one used to write data + +# S3_PUBLIC_URL=https://www.example.com + + +# === Mail configuration === + +MAIL_TRANSPORT=local # smtp or postmark, anything else uses the local adapter MAIL_FROM=noreply@claper.co MAIL_FROM_NAME=Claper -#SMTP_RELAY=xx.example.com -#SMTP_USERNAME=johndoe@example.com -#SMTP_PASSWORD=xxx -#SMTP_PORT=465 +# == Use the following if MAIL_TRANSPORT=smtp -#ENABLE_MAILBOX_ROUTE=false -#MAILBOX_USER=admin -#MAILBOX_PASSWORD=admin +# SMTP_RELAY=smtp.example.com +# SMTP_PORT=465 +# SMTP_RETRIES=1 +# SMTP_NO_MX_LOOKUPS=false -# Claper configuration +# SMTP_AUTH=always # if_available, always or never +# SMTP_USERNAME=username +# SMTP_PASSWORD=xxx -#ENABLE_ACCOUNT_CREATION=true -#EMAIL_CONFIRMATION=true -#ALLOW_UNLINK_EXTERNAL_PROVIDER=false -#LOGOUT_REDIRECT_URL=https://google.com -#GS_JPG_RESOLUTION=300x300 -#LANGUAGES=en,fr,es,it,nl,de +# SMTP_SSL=false +# SMTP_TLS=if_available # if_available, always or never +# SMTP_SSL_DEPTH=2 +# SMTP_SSL_SERVER=*.example.com -# OIDC configuration +# == Use the following if MAIL_TRANSPORT=postmark + +# POSTMARK_API_KEY=xxx + +# == Dev mailbox + +# ENABLE_MAILBOX_ROUTE=false +# MAILBOX_USER=admin +# MAILBOX_PASSWORD=admin + + +# === Claper configuration === + +# ENABLE_ACCOUNT_CREATION=true +# EMAIL_CONFIRMATION=true +# ALLOW_UNLINK_EXTERNAL_PROVIDER=false +# LOGOUT_REDIRECT_URL=https://google.com +# GS_JPG_RESOLUTION=300x300 +# LANGUAGES=en,fr,es,it,nl,de + + +# === OIDC configuration === # OIDC_PROVIDER_NAME="OpenID" # OIDC_ISSUER=https://my-idp.example/application/o/claper/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 358fbac..48a0f8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,19 @@ -### v.2.4.1 +### v.2.5.0 + +### Security + +- Fix stored XSS vulnerability in custom embed iframes via input sanitization with attribute whitelisting +- Fix XSS vulnerability in URL link formatting by escaping user-submitted URLs +- Fix IDOR on form export endpoint by adding authorization check +- Fix atom exhaustion DoS by replacing `String.to_atom/1` on user input with explicit whitelists (8 locations) +- Add rate limiting on authentication endpoints using Hammer 7.0 + +### Fixes and improvements + +- Fix form submission crash for anonymous attendees +- Improve SMTP config and handling (#197) +- Fix presentation slides URL (#200) +- Fix custom S3 endpoint (#199) ### v.2.4.0 diff --git a/config/runtime.exs b/config/runtime.exs index 4f3b500..843177b 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -76,16 +76,6 @@ email_confirmation = pool_size = get_int_from_path_or_env(config_dir, "POOL_SIZE", 10) queue_target = get_int_from_path_or_env(config_dir, "QUEUE_TARGET", 5_000) -mail_transport = get_var_from_path_or_env(config_dir, "MAIL_TRANSPORT", "local") - -smtp_relay = get_var_from_path_or_env(config_dir, "SMTP_RELAY", nil) -smtp_username = get_var_from_path_or_env(config_dir, "SMTP_USERNAME", nil) -smtp_password = get_var_from_path_or_env(config_dir, "SMTP_PASSWORD", nil) -smtp_ssl = get_var_from_path_or_env(config_dir, "SMTP_SSL", "true") |> String.to_existing_atom() -smtp_tls = get_var_from_path_or_env(config_dir, "SMTP_TLS", "always") -smtp_auth = get_var_from_path_or_env(config_dir, "SMTP_AUTH", "always") -smtp_port = get_int_from_path_or_env(config_dir, "SMTP_PORT", 25) - storage = get_var_from_path_or_env(config_dir, "PRESENTATION_STORAGE", "local") if storage not in ["local", "s3"], do: raise("Invalid PRESENTATION_STORAGE value #{storage}") @@ -222,26 +212,45 @@ config :claper, ClaperWeb.MailboxGuard, get_var_from_path_or_env(config_dir, "ENABLE_MAILBOX_ROUTE", "false") |> String.to_existing_atom() -case mail_transport do +case get_var_from_path_or_env(config_dir, "MAIL_TRANSPORT", "local") do "smtp" -> + relay = get_var_from_path_or_env(config_dir, "SMTP_RELAY", nil) + ssl = get_var_from_path_or_env(config_dir, "SMTP_SSL", "true") + depth = get_int_from_path_or_env(config_dir, "SMTP_SSL_DEPTH", 2) + + server = + get_var_from_path_or_env(config_dir, "SMTP_SSL_SERVER", relay) + |> to_charlist() + config :claper, Claper.Mailer, - adapter: Swoosh.Adapters.Mua, - relay: smtp_relay, - port: smtp_port - - cond do - smtp_username && smtp_password -> - config :claper, Claper.Mailer, auth: [username: smtp_username, password: smtp_password] - - smtp_username || smtp_password -> - raise ArgumentError, """ - Both SMTP_USERNAME and SMTP_PASSWORD must be set for SMTP authentication. - Please provide values for both environment variables. - """ - - true -> - nil - end + adapter: Swoosh.Adapters.SMTP, + relay: relay, + port: get_int_from_path_or_env(config_dir, "SMTP_PORT", 465), + auth: get_var_from_path_or_env(config_dir, "SMTP_AUTH", "always"), + username: get_var_from_path_or_env(config_dir, "SMTP_USERNAME", ""), + password: get_var_from_path_or_env(config_dir, "SMTP_PASSWORD", ""), + retries: get_int_from_path_or_env(config_dir, "SMTP_RETRIES", 1), + no_mx_lookups: get_var_from_path_or_env(config_dir, "SMTP_NO_MX_LOOKUPS", "false"), + ssl: ssl, + sockopts: + if(ssl == "true", + do: [ + versions: [:"tlsv1.3", :"tlsv1.2"], + verify: :verify_peer, + cacerts: :public_key.cacerts_get(), + depth: depth, + server_name_indication: server + ], + else: [] + ), + tls: get_var_from_path_or_env(config_dir, "SMTP_TLS", "if_available"), + tls_options: [ + versions: [:"tlsv1.3", :"tlsv1.2"], + verify: :verify_peer, + cacerts: :public_key.cacerts_get(), + depth: depth, + server_name_indication: server + ] config :swoosh, :api_client, false @@ -250,7 +259,7 @@ case mail_transport do adapter: Swoosh.Adapters.Postmark, api_key: get_var_from_path_or_env(config_dir, "POSTMARK_API_KEY", nil) - config :swoosh, :api_client, Swoosh.ApiClient.Hackney + config :swoosh, :api_client, Swoosh.ApiClient.Finch _ -> config :claper, Claper.Mailer, adapter: Swoosh.Adapters.Local @@ -267,5 +276,3 @@ if s3_scheme && s3_host do config :ex_aws, s3: [scheme: s3_scheme, host: s3_host, port: s3_port] end - -config :swoosh, :api_client, Swoosh.ApiClient.Finch diff --git a/lib/claper/application.ex b/lib/claper/application.ex index 80100d6..865e69e 100644 --- a/lib/claper/application.ex +++ b/lib/claper/application.ex @@ -19,6 +19,8 @@ defmodule Claper.Application do ClaperWeb.Telemetry, # Start the PubSub system {Phoenix.PubSub, name: Claper.PubSub}, + # Start the rate limiter before the endpoint accepts requests + Claper.RateLimit, # Start the Endpoint (http/https) ClaperWeb.Presence, ClaperWeb.Endpoint, diff --git a/lib/claper/embeds/embed.ex b/lib/claper/embeds/embed.ex index f05bc9d..bff2071 100644 --- a/lib/claper/embeds/embed.ex +++ b/lib/claper/embeds/embed.ex @@ -97,9 +97,62 @@ defmodule Claper.Embeds.Embed do |> validate_format(:content, ~r//s, message: gettext("Please enter valid HTML content with an iframe tag") ) + |> sanitize_custom_embed() _ -> changeset end end + + @allowed_iframe_attrs ~w(src width height frameborder allow allowfullscreen style title loading referrerpolicy sandbox) + + defp sanitize_custom_embed(%{valid?: false} = changeset), do: changeset + + defp sanitize_custom_embed(changeset) do + content = get_field(changeset, :content) + + case Regex.run(~r/]*?(?:\/>|>[\s\S]*?<\/iframe>)/i, content) do + [iframe_tag] -> + put_change(changeset, :content, sanitize_iframe_tag(iframe_tag)) + + _ -> + add_error( + changeset, + :content, + gettext("Please enter valid HTML content with an iframe tag") + ) + end + end + + @allowed_boolean_attrs ~w(allowfullscreen sandbox) + + defp sanitize_iframe_tag(iframe_tag) do + # Extract key="value" attributes + value_attrs = + Regex.scan(~r/([\w-]+)\s*=\s*(?:"([^"]*?)"|'([^']*?)')/i, iframe_tag) + |> Enum.filter(fn [_, name | _] -> String.downcase(name) in @allowed_iframe_attrs end) + |> Enum.reject(fn [_, name, value | _] -> + String.downcase(name) == "src" and String.trim(value) =~ ~r/^javascript:/i + end) + |> Enum.map(fn [_, name, value | _rest] -> + ~s(#{String.downcase(name)}="#{String.replace(value, "\"", """)}") + end) + + # Extract standalone boolean attributes (e.g., allowfullscreen) + value_attr_names = + Regex.scan(~r/([\w-]+)\s*=/i, iframe_tag) + |> Enum.map(fn [_, name] -> String.downcase(name) end) + |> MapSet.new() + + boolean_attrs = + Regex.scan(~r/\s([\w-]+)(?=[\s>\/])/i, iframe_tag) + |> Enum.map(fn [_, name] -> String.downcase(name) end) + |> Enum.filter(&(&1 in @allowed_boolean_attrs)) + |> Enum.reject(&MapSet.member?(value_attr_names, &1)) + |> Enum.uniq() + + all_attrs = Enum.join(value_attrs ++ boolean_attrs, " ") + + if all_attrs == "", do: "", else: "" + end end diff --git a/lib/claper/forms.ex b/lib/claper/forms.ex index f05daf0..2ec8931 100644 --- a/lib/claper/forms.ex +++ b/lib/claper/forms.ex @@ -324,6 +324,9 @@ defmodule Claper.Forms do nil -> broadcast({:ok, r, event_uuid}, :form_submit_created) _form_submit -> broadcast({:ok, r, event_uuid}, :form_submit_updated) end + + {:error, changeset} -> + {:error, changeset} end end @@ -344,6 +347,7 @@ defmodule Claper.Forms do |> Repo.delete() |> case do {:ok, r} -> broadcast({:ok, r, event_uuid}, :form_submit_deleted) + {:error, changeset} -> {:error, changeset} end end diff --git a/lib/claper/forms/form_submit.ex b/lib/claper/forms/form_submit.ex index f0c490f..3570e8d 100644 --- a/lib/claper/forms/form_submit.ex +++ b/lib/claper/forms/form_submit.ex @@ -25,6 +25,18 @@ defmodule Claper.Forms.FormSubmit do def changeset(form_submit, attrs) do form_submit |> cast(attrs, [:attendee_identifier, :user_id, :form_id, :response]) - |> validate_required([:form_id, :user_id, :response]) + |> validate_required([:form_id, :response]) + |> validate_user_or_attendee() + end + + defp validate_user_or_attendee(changeset) do + user_id = get_field(changeset, :user_id) + attendee_identifier = get_field(changeset, :attendee_identifier) + + if is_nil(user_id) and is_nil(attendee_identifier) do + add_error(changeset, :user_id, "either user_id or attendee_identifier must be present") + else + changeset + end end end diff --git a/lib/claper/presentations.ex b/lib/claper/presentations.ex index 52840ff..6427abe 100644 --- a/lib/claper/presentations.ex +++ b/lib/claper/presentations.ex @@ -96,6 +96,10 @@ defmodule Claper.Presentations do Returns a list of JPG slide URLs for a given presentation `hash` and `length`. See also `get_slide_urls/1`. """ + def get_slide_urls(hash, length) + + def get_slide_urls(nil, _), do: [] + def get_slide_urls(hash, length) when is_binary(hash) and is_integer(length) do config = Application.get_env(:claper, :presentations) diff --git a/lib/claper/rate_limit.ex b/lib/claper/rate_limit.ex new file mode 100644 index 0000000..95ba4a3 --- /dev/null +++ b/lib/claper/rate_limit.ex @@ -0,0 +1,3 @@ +defmodule Claper.RateLimit do + use Hammer, backend: :ets +end diff --git a/lib/claper/tasks/converter.ex b/lib/claper/tasks/converter.ex index ea2d8df..8d29eb9 100644 --- a/lib/claper/tasks/converter.ex +++ b/lib/claper/tasks/converter.ex @@ -30,7 +30,14 @@ defmodule Claper.Tasks.Converter do IO.puts("Starting conversion for #{hash}... (copy: #{is_copy})") - file_to_pdf(String.to_atom(ext), path, file) + ext_atom = + case ext do + "ppt" -> :ppt + "pptx" -> :pptx + other -> other + end + + file_to_pdf(ext_atom, path, file) |> pdf_to_jpg(path, presentation, user_id) |> jpg_upload(hash, path, presentation, user_id, is_copy) end diff --git a/lib/claper_web/controllers/stat_controller.ex b/lib/claper_web/controllers/stat_controller.ex index 8b4f4bf..81e310c 100644 --- a/lib/claper_web/controllers/stat_controller.ex +++ b/lib/claper_web/controllers/stat_controller.ex @@ -10,20 +10,27 @@ defmodule ClaperWeb.StatController do @doc """ Exports form submissions as a CSV file. """ - def export_form(conn, %{"form_id" => form_id}) do - form = Forms.get_form!(form_id, [:form_submits]) - headers = form.fields |> Enum.map(& &1.name) + def export_form(%{assigns: %{current_user: current_user}} = conn, %{"form_id" => form_id}) do + with form <- Forms.get_form!(form_id, [:form_submits]), + presentation_file <- + Presentations.get_presentation_file!(form.presentation_file_id, [:event]), + event <- presentation_file.event, + :ok <- authorize_event_access(current_user, event) do + headers = form.fields |> Enum.map(& &1.name) - data = - form.form_submits - |> Enum.map(fn submit -> - form.fields - |> Enum.map(fn field -> - Map.get(submit.response, field.name, "") + data = + form.form_submits + |> Enum.map(fn submit -> + form.fields + |> Enum.map(fn field -> + Map.get(submit.response, field.name, "") + end) end) - end) - export_as_csv(conn, headers, data, "form-#{sanitize(form.title)}") + export_as_csv(conn, headers, data, "form-#{sanitize(form.title)}") + else + :unauthorized -> send_resp(conn, 403, "Forbidden") + end end @doc """ diff --git a/lib/claper_web/helpers.ex b/lib/claper_web/helpers.ex index 99c5598..0b6ad42 100644 --- a/lib/claper_web/helpers.ex +++ b/lib/claper_web/helpers.ex @@ -6,8 +6,10 @@ defmodule ClaperWeb.Helpers do |> String.split(url_regex, include_captures: true) |> Enum.map(fn "http" <> _rest = url -> + escaped = url |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string() + Phoenix.HTML.raw( - ~s(#{url}) + ~s(#{escaped}) ) text -> diff --git a/lib/claper_web/live/admin_live/dashboard_live.ex b/lib/claper_web/live/admin_live/dashboard_live.ex index af6e07d..eb8526b 100644 --- a/lib/claper_web/live/admin_live/dashboard_live.ex +++ b/lib/claper_web/live/admin_live/dashboard_live.ex @@ -34,7 +34,13 @@ defmodule ClaperWeb.AdminLive.DashboardLive do @impl true def handle_event("change_period", %{"period" => period}, socket) do - period_atom = String.to_atom(period) + period_atom = + case period do + "day" -> :day + "week" -> :week + "month" -> :month + _ -> :day + end days_back = case period_atom do diff --git a/lib/claper_web/live/admin_live/table_actions_component.ex b/lib/claper_web/live/admin_live/table_actions_component.ex index 4ccd225..c8c0620 100644 --- a/lib/claper_web/live/admin_live/table_actions_component.ex +++ b/lib/claper_web/live/admin_live/table_actions_component.ex @@ -231,10 +231,16 @@ defmodule ClaperWeb.AdminLive.TableActionsComponent do action = Enum.find(socket.assigns.dropdown_actions, &(&1.key == action_key)) if action do - send( - self(), - {:table_action, String.to_atom(action_key), socket.assigns.item, socket.assigns.item_id} - ) + try do + action_atom = String.to_existing_atom(action_key) + + send( + self(), + {:table_action, action_atom, socket.assigns.item, socket.assigns.item_id} + ) + rescue + ArgumentError -> :ok + end end {:noreply, assign(socket, dropdown_open: false)} diff --git a/lib/claper_web/live/event_live/form_component.ex b/lib/claper_web/live/event_live/form_component.ex index 2862ad5..b601e45 100644 --- a/lib/claper_web/live/event_live/form_component.ex +++ b/lib/claper_web/live/event_live/form_component.ex @@ -61,7 +61,7 @@ defmodule ClaperWeb.EventLive.FormComponent do form={f} labelClass="text-white" fieldClass="bg-gray-700 text-white" - key={String.to_atom(field.name)} + key={safe_field_atom(field.name)} name={field.name} required={field.required} value={ @@ -75,7 +75,7 @@ defmodule ClaperWeb.EventLive.FormComponent do form={f} labelClass="text-white" fieldClass="bg-gray-700 text-white" - key={String.to_atom(field.name)} + key={safe_field_atom(field.name)} name={field.name} required={field.required} value={ @@ -148,6 +148,9 @@ defmodule ClaperWeb.EventLive.FormComponent do {:noreply, socket |> assign(:current_form_submit, form_submit)} + + {:error, _changeset} -> + {:noreply, socket} end end @@ -167,9 +170,21 @@ defmodule ClaperWeb.EventLive.FormComponent do {:noreply, socket |> assign(:current_form_submit, form_submit)} + + {:error, _changeset} -> + {:noreply, socket} end end + defp safe_field_atom(name) when is_binary(name) do + String.to_existing_atom(name) + rescue + ArgumentError -> + if Regex.match?(~r/^[\w]{1,100}$/, name), + do: String.to_atom(name), + else: :invalid_field + end + def toggle_form(js \\ %JS{}) do js |> JS.toggle( diff --git a/lib/claper_web/live/event_live/manage.ex b/lib/claper_web/live/event_live/manage.ex index f54840e..5f361e7 100644 --- a/lib/claper_web/live/event_live/manage.ex +++ b/lib/claper_web/live/event_live/manage.ex @@ -711,36 +711,42 @@ defmodule ClaperWeb.EventLive.Manage do @impl true def handle_event("list-tab", %{"tab" => tab}, socket) do - socket = assign(socket, :list_tab, String.to_atom(tab)) - - socket = + {tab_atom, socket} = case tab do "posts" -> - socket - |> stream(:posts, list_all_posts(socket, socket.assigns.event.uuid), reset: true) + {:posts, + stream(socket, :posts, list_all_posts(socket, socket.assigns.event.uuid), reset: true)} "questions" -> - socket - |> stream(:questions, list_all_questions(socket, socket.assigns.event.uuid), - reset: true - ) + {:questions, + stream(socket, :questions, list_all_questions(socket, socket.assigns.event.uuid), + reset: true + )} "forms" -> - stream( - socket, - :form_submits, - list_form_submits(socket, socket.assigns.event.presentation_file.id), - reset: true - ) + {:forms, + stream( + socket, + :form_submits, + list_form_submits(socket, socket.assigns.event.presentation_file.id), + reset: true + )} "pinned_posts" -> - socket - |> stream(:pinned_posts, list_pinned_posts(socket, socket.assigns.event.uuid), - reset: true - ) + {:pinned_posts, + stream( + socket, + :pinned_posts, + list_pinned_posts(socket, socket.assigns.event.uuid), + reset: true + )} + + _ -> + {:posts, + stream(socket, :posts, list_all_posts(socket, socket.assigns.event.uuid), reset: true)} end - {:noreply, socket} + {:noreply, assign(socket, :list_tab, tab_atom)} end @impl true @@ -953,7 +959,13 @@ defmodule ClaperWeb.EventLive.Manage do end defp list_all_questions(_socket, event_id, sort \\ "date") do - Claper.Posts.list_questions(event_id, [:event, :reactions], String.to_atom(sort)) + sort_atom = + case sort do + "likes" -> :likes + _ -> :date + end + + Claper.Posts.list_questions(event_id, [:event, :reactions], sort_atom) |> Enum.filter(&(ClaperWeb.Helpers.body_without_links(&1.body) =~ "?")) end diff --git a/lib/claper_web/live/event_live/show.ex b/lib/claper_web/live/event_live/show.ex index 6b5a512..116f9cf 100644 --- a/lib/claper_web/live/event_live/show.ex +++ b/lib/claper_web/live/event_live/show.ex @@ -7,6 +7,19 @@ defmodule ClaperWeb.EventLive.Show do on_mount(ClaperWeb.AttendeeLiveAuth) + @global_react_types %{ + "heart" => :heart, + "clap" => :clap, + "hundred" => :hundred, + "raisehand" => :raisehand + } + + @reaction_fields %{ + like: {:like_count, :like_posts}, + love: {:love_count, :love_posts}, + lol: {:lol_count, :lol_posts} + } + @impl true def mount(%{"code" => code}, session, socket) do with %{"locale" => locale} <- session do @@ -422,13 +435,19 @@ defmodule ClaperWeb.EventLive.Show do %{"type" => type}, socket ) do - Phoenix.PubSub.broadcast( - Claper.PubSub, - "event:#{socket.assigns.event.uuid}", - {:react, String.to_atom(type)} - ) + case Map.get(@global_react_types, type) do + nil -> + {:noreply, socket} - {:noreply, socket} + type_atom -> + Phoenix.PubSub.broadcast( + Claper.PubSub, + "event:#{socket.assigns.event.uuid}", + {:react, type_atom} + ) + + {:noreply, socket} + end end @impl true @@ -752,8 +771,7 @@ defmodule ClaperWeb.EventLive.Show do defp add_reaction(socket, post_id, params, type) do with post <- Posts.get_post!(post_id, [:event]), {:ok, _} <- Posts.create_reaction(Map.merge(params, %{post: post})) do - count_field = String.to_atom("#{type}_count") - posts_field = String.to_atom("#{type}_posts") + {count_field, posts_field} = @reaction_fields[type] {:ok, _} = Posts.update_post(post, %{count_field => Map.get(post, count_field) + 1}) update(socket, posts_field, fn posts -> [post.id | posts] end) @@ -763,8 +781,7 @@ defmodule ClaperWeb.EventLive.Show do defp remove_reaction(socket, post_id, params, type) do with post <- Posts.get_post!(post_id, [:event]), {:ok, _} <- Posts.delete_reaction(Map.merge(params, %{post: post})) do - count_field = String.to_atom("#{type}_count") - posts_field = String.to_atom("#{type}_posts") + {count_field, posts_field} = @reaction_fields[type] {:ok, _} = Posts.update_post(post, %{count_field => Map.get(post, count_field) - 1}) update(socket, posts_field, fn posts -> List.delete(posts, post.id) end) diff --git a/lib/claper_web/plugs/rate_limit_plug.ex b/lib/claper_web/plugs/rate_limit_plug.ex new file mode 100644 index 0000000..6a617e4 --- /dev/null +++ b/lib/claper_web/plugs/rate_limit_plug.ex @@ -0,0 +1,34 @@ +defmodule ClaperWeb.Plugs.RateLimitPlug do + @moduledoc """ + Plug for rate limiting requests based on client IP address. + + ## Usage + + plug ClaperWeb.Plugs.RateLimitPlug, max_requests: 10, interval_ms: 60_000, prefix: "login" + """ + import Plug.Conn + + def init(opts) do + %{ + max_requests: Keyword.get(opts, :max_requests, 10), + interval_ms: Keyword.get(opts, :interval_ms, 60_000), + prefix: Keyword.get(opts, :prefix, "default") + } + end + + def call(conn, %{max_requests: max_requests, interval_ms: interval_ms, prefix: prefix}) do + ip = conn.remote_ip |> :inet.ntoa() |> to_string() + key = "#{prefix}:#{ip}" + + case Claper.RateLimit.hit(key, interval_ms, max_requests) do + {:allow, _count} -> + conn + + {:deny, _retry_after} -> + conn + |> put_resp_content_type("text/plain") + |> send_resp(429, "Too Many Requests") + |> halt() + end + end +end diff --git a/lib/claper_web/router.ex b/lib/claper_web/router.ex index bd049f3..67c93ae 100644 --- a/lib/claper_web/router.ex +++ b/lib/claper_web/router.ex @@ -35,6 +35,13 @@ defmodule ClaperWeb.Router do plug(:accepts, ["json"]) end + pipeline :rate_limit_auth do + plug ClaperWeb.Plugs.RateLimitPlug, + max_requests: 10, + interval_ms: 60_000, + prefix: "auth" + end + # Manage attendee_identifier in requests pipeline :attendee_registration do plug(:attendee_identifier) @@ -120,7 +127,7 @@ defmodule ClaperWeb.Router do ## Authentication routes scope "/", ClaperWeb do - pipe_through([:browser, :redirect_if_user_is_authenticated]) + pipe_through([:browser, :redirect_if_user_is_authenticated, :rate_limit_auth]) get("/users/register", UserRegistrationController, :new) post("/users/register", UserRegistrationController, :create) diff --git a/mix.exs b/mix.exs index 6bca2fa..48e0aa6 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Claper.MixProject do use Mix.Project - @version "2.4.1" + @version "2.5.0" def project do [ @@ -95,6 +95,7 @@ defmodule Claper.MixProject do {:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, {:dart_sass, "~> 0.7", runtime: Mix.env() == :dev}, {:swoosh, "~> 1.19"}, + {:gen_smtp, "~> 1.3"}, {:finch, "~> 0.19"}, {:telemetry_metrics, "~> 1.1"}, {:telemetry_poller, "~> 1.2"}, @@ -105,7 +106,6 @@ defmodule Claper.MixProject do {:hashids, "~> 2.1"}, {:libcluster, "~> 3.5"}, {:porcelain, "~> 2.0"}, - {:hackney, "~> 1.24"}, {:csv, "~> 3.2"}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:joken, "~> 2.6"}, @@ -114,8 +114,7 @@ defmodule Claper.MixProject do {:uuid, "~> 1.1"}, {:oidcc, "~> 3.5"}, {:oban, "~> 2.19"}, - {:mua, "~> 0.2"}, - {:mail, "~> 0.5"}, + {:hammer, "~> 7.0"}, {:tailwind, "~> 0.3", runtime: Mix.env() == :dev} ] end diff --git a/mix.lock b/mix.lock index 595e357..4595508 100644 --- a/mix.lock +++ b/mix.lock @@ -2,10 +2,7 @@ "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"}, - "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"}, - "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, "cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.15.0", "3c97a318a933962d1c12b96ab7c1d728267d2c523c25a5b57b0f93392b6e9e25", [:make, :rebar3], [], "hexpm", "4f00c879a64b4fe7c8fcb42a4281925e9ffdb928820b03c3ad325a617e857532"}, @@ -26,36 +23,25 @@ "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"}, "floki": {:hex, :floki, "0.38.0", "62b642386fa3f2f90713f6e231da0fa3256e41ef1089f83b6ceac7a3fd3abf33", [:mix], [], "hexpm", "a5943ee91e93fb2d635b612caf5508e36d37548e84928463ef9dd986f0d1abd9"}, - "gen_smtp": {:hex, :gen_smtp, "1.2.0", "9cfc75c72a8821588b9b9fe947ae5ab2aed95a052b81237e0928633a13276fd3", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "5ee0375680bca8f20c4d85f58c2894441443a743355430ff33a783fe03296779"}, + "gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, - "hackney": {:hex, :hackney, "1.24.1", "f5205a125bba6ed4587f9db3cc7c729d11316fa8f215d3e57ed1c067a9703fa9", [: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.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "f4a7392a0b53d8bbc3eb855bdcc919cd677358e65b2afd3840b5b3690c4c8a39"}, + "hammer": {:hex, :hammer, "7.2.0", "73113eca87f0fd20a6d3679c1182e8c4c1778266f61de4e9dc8c589dee156c30", [:mix], [], "hexpm", "c50fa865ddfe7b3d4f8a6941f56940679e02a9a1465b00668a95d140b101d828"}, "hashids": {:hex, :hashids, "2.1.0", "aabbcc4f9fa0b460cc6ef629f5bcbc35e7e87b382fee79f9c50be40b86574288", [:mix], [], "hexpm", "172163b1642d415881ef1c6e1f1be12d4e92b0711d5bbbd8854f82a1ce32d60b"}, - "honeybadger": {:hex, :honeybadger, "0.18.1", "f61f71147d9e6ce8cdc6114e5df5eed4d2daa32dd15308267a1dacf5fa88b1e9", [:mix], [{:ecto, ">= 2.0.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:hackney, "~> 1.1", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.0.0 and < 2.0.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, ">= 1.0.0 and < 2.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "693e14b1b7d254dd75f977240c208d6b69cc1cbdd515bdd5b8b1738a1baf5fd5"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, - "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, - "httpoison": {:hex, :httpoison, "2.2.1", "87b7ed6d95db0389f7df02779644171d7319d319178f6680438167d7b69b1f3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "51364e6d2f429d80e14fe4b5f8e39719cacd03eb3f9a9286e61e216feac2d2df"}, - "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"}, "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "libcluster": {:hex, :libcluster, "3.5.0", "5ee4cfde4bdf32b2fef271e33ce3241e89509f4344f6c6a8d4069937484866ba", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebf6561fcedd765a4cd43b4b8c04b1c87f4177b5fb3cbdfe40a780499d72f743"}, - "lti_1p3": {:hex, :lti_1p3, "0.6.0", "e896c56b0ae067b768fb5ce2c44db305b6d7bbc213fafd1bbb7da586629c6d9f", [:mix], [{:httpoison, "~> 2.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.3", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.2.0", [hex: :joken, repo: "hexpm", optional: false]}, {:timex, "~> 3.5", [hex: :timex, repo: "hexpm", optional: false]}, {:uuid, "~> 1.1", [hex: :uuid, repo: "hexpm", optional: false]}], "hexpm", "7c142ae8027913bbaa2267a28f448e734b1c5b2563e2aee2b1481632b84e35b3"}, - "lti_1p3_ecto_provider": {:hex, :lti_1p3_ecto_provider, "0.6.0", "7d8f3293b2dd32c243a55b5020ff0e37c5372ba8669bf82944b173d65f22a8e3", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:httpoison, "~> 2.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:lti_1p3, "~> 0.6.0", [hex: :lti_1p3, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:timex, "~> 3.5", [hex: :timex, repo: "hexpm", optional: false]}, {:uuid, "~> 1.1", [hex: :uuid, repo: "hexpm", optional: false]}], "hexpm", "b825e745ccd4dfb840bf1217056f535dd1c50ecbe673917c3b17efc4a64246d1"}, - "mail": {:hex, :mail, "0.5.1", "6383a61620aea24675c96e34b9019dede1bfc9a37ee10ce5a5cafcc7c5e48743", [:mix], [], "hexpm", "595144340b74f23d651ea2b4a72a896819940478d7425dfa302ea3b5c9041ec9"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, - "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"}, - "mua": {:hex, :mua, "0.2.4", "a9172ab0a1ac8732cf2699d739ceac3febcb9b4ffc540260ad2e32c0b6632af9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "e7e4dacd5ad65f13e3542772e74a159c00bd2d5579e729e9bb72d2c73a266fb7"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "oban": {:hex, :oban, "2.19.4", "045adb10db1161dceb75c254782f97cdc6596e7044af456a59decb6d06da73c1", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5fcc6219e6464525b808d97add17896e724131f498444a292071bf8991c99f97"}, "oidcc": {:hex, :oidcc, "3.5.2", "91b0097a3fee86abb1aabfd80f2f910439688287408d916fed3535724b4fb897", [:mix, :rebar3], [{:igniter, "~> 0.5.50", [hex: :igniter, repo: "hexpm", optional: true]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.3.1", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "474e7eddbf90ec4c8c50aeefe9a1ffb46ced72e6a11c2bb0c6eece9794821c2a"}, - "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"}, @@ -70,12 +56,11 @@ "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_cowboy": {:hex, :plug_cowboy, "2.7.4", "729c752d17cf364e2b8da5bdb34fb5804f56251e88bb602aff48ae0bd8673d11", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9b85632bd7012615bae0a5d70084deb1b25d2bcbb32cab82d1e9a1e023168aa3"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, "porcelain": {:hex, :porcelain, "2.0.3", "2d77b17d1f21fed875b8c5ecba72a01533db2013bd2e5e62c6d286c029150fdc", [:mix], [], "hexpm", "dc996ab8fadbc09912c787c7ab8673065e50ea1a6245177b0c24569013d23620"}, "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"}, "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "req": {:hex, :req, "0.5.14", "521b449fa0bf275e6d034c05f29bec21789a0d6cd6f7a1c326c7bee642bf6e07", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "b7b15692071d556c73432c7797aa7e96b51d1a2db76f746b976edef95c930021"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, - "stripity_stripe": {:hex, :stripity_stripe, "2.13.0", "b9ea806fcf46e85232b75f2145c34770b17faa44c59cdd13ff493aaa6e84b4a9", [:mix], [{:hackney, "~> 1.15", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}, {:uri_query, "~> 0.1.2", [hex: :uri_query, repo: "hexpm", optional: false]}], "hexpm", "d6931ed9816552320f95428fd997edf15e99a913ca78fc4342d5516b98f42476"}, "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"}, "tailwind": {:hex, :tailwind, "0.3.1", "a89d2835c580748c7a975ad7dd3f2ea5e63216dc16d44f9df492fbd12c094bed", [:mix], [], "hexpm", "98a45febdf4a87bc26682e1171acdedd6317d0919953c353fcd1b4f9f4b676a2"}, @@ -83,11 +68,6 @@ "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"}, "telemetry_registry": {:hex, :telemetry_registry, "0.3.2", "701576890320be6428189bff963e865e8f23e0ff3615eade8f78662be0fc003c", [:mix, :rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7ed191eb1d115a3034af8e1e35e4e63d5348851d556646d46ca3d1b4e16bab9"}, - "timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"}, - "tls_certificate_check": {:hex, :tls_certificate_check, "1.25.0", "702b1835fe718a52310509537392abd067dbe941ebc05fe72409d2b2f8061651", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "167343ccf50538cf2faf61a3f1460e749b3edf2ecef55516af2b5834362abcb1"}, - "tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, - "uri_query": {:hex, :uri_query, "0.1.2", "ae35b83b472f3568c2c159eee3f3ccf585375d8a94fb5382db1ea3589e75c3b4", [:mix], [], "hexpm", "e3bc81816c98502c36498b9b2f239b89c71ce5eadfff7ceb2d6c0a2e6ae2ea0c"}, "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm", "c790593b4c3b601f5dc2378baae7efaf5b3d73c4c6456ba85759905be792f2ac"}, "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"}, diff --git a/test/claper/forms_test.exs b/test/claper/forms_test.exs index 5a299b2..63f1ccb 100644 --- a/test/claper/forms_test.exs +++ b/test/claper/forms_test.exs @@ -126,5 +126,62 @@ defmodule Claper.FormsTest do } ) end + + test "create_or_update_form_submit/2 with attendee_identifier creates a form_submit" do + presentation_file = presentation_file_fixture(%{}, [:event]) + f = form_fixture(%{presentation_file_id: presentation_file.id}) + + assert {:ok, %Claper.Forms.FormSubmit{} = form_submit} = + Forms.create_or_update_form_submit( + presentation_file.event.uuid, + %{ + "attendee_identifier" => "test-attendee-123", + "form_id" => f.id, + "response" => %{"Name" => "Daniel"} + } + ) + + assert form_submit.attendee_identifier == "test-attendee-123" + assert is_nil(form_submit.user_id) + assert form_submit.form_id == f.id + end + + test "create_or_update_form_submit/2 with attendee_identifier updates existing form_submit" do + presentation_file = presentation_file_fixture(%{}, [:event]) + f = form_fixture(%{presentation_file_id: presentation_file.id}) + + {:ok, _first_submit} = + Forms.create_or_update_form_submit( + presentation_file.event.uuid, + %{ + "attendee_identifier" => "test-attendee-123", + "form_id" => f.id, + "response" => %{"Name" => "Daniel"} + } + ) + + assert {:ok, %Claper.Forms.FormSubmit{} = updated_submit} = + Forms.create_or_update_form_submit( + presentation_file.event.uuid, + %{ + "attendee_identifier" => "test-attendee-123", + "form_id" => f.id, + "response" => %{"Name" => "Updated Name"} + } + ) + + assert updated_submit.response == %{"Name" => "Updated Name"} + end + + test "create_or_update_form_submit/2 without user_id or attendee_identifier returns error" do + presentation_file = presentation_file_fixture(%{}, [:event]) + f = form_fixture(%{presentation_file_id: presentation_file.id}) + + assert {:error, %Ecto.Changeset{}} = + Forms.create_form_submit(%{ + form_id: f.id, + response: %{"Name" => "Daniel"} + }) + end end end