feat: add custom boolean field state & URL-Parameter

This commit is contained in:
Simon 2026-01-20 15:55:08 +01:00
parent dbec2d020f
commit 37e1553a02
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
4 changed files with 264 additions and 40 deletions

View file

@ -19,6 +19,12 @@ defmodule Mv.Constants do
@custom_field_prefix "custom_field_"
@boolean_filter_prefix "bf_"
@max_boolean_filters 50
@max_uuid_length 36
@email_validator_checks [:html_input, :pow]
def member_fields, do: @member_fields
@ -33,6 +39,42 @@ defmodule Mv.Constants do
"""
def custom_field_prefix, do: @custom_field_prefix
@doc """
Returns the prefix used for boolean custom field filter URL parameters.
## Examples
iex> Mv.Constants.boolean_filter_prefix()
"bf_"
"""
def boolean_filter_prefix, do: @boolean_filter_prefix
@doc """
Returns the maximum number of boolean custom field filters allowed per request.
This limit prevents DoS attacks by restricting the number of filter parameters
that can be processed in a single request.
## Examples
iex> Mv.Constants.max_boolean_filters()
50
"""
def max_boolean_filters, do: @max_boolean_filters
@doc """
Returns the maximum length of a UUID string (36 characters including hyphens).
UUIDs in standard format (e.g., "550e8400-e29b-41d4-a716-446655440000") are
exactly 36 characters long. This constant is used for input validation.
## Examples
iex> Mv.Constants.max_uuid_length()
36
"""
def max_uuid_length, do: @max_uuid_length
@doc """
Returns the email validator checks used for EctoCommons.EmailValidator.

View file

@ -303,7 +303,9 @@ defmodule Mv.Membership.Import.MemberCSV do
{inserted, failed, errors, _collected_error_count, truncated?} =
Enum.reduce(chunk_rows_with_lines, {0, 0, [], 0, false}, fn {line_number, row_map},
{acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?} ->
{acc_inserted, acc_failed,
acc_errors, acc_error_count,
acc_truncated?} ->
current_error_count = existing_error_count + acc_error_count
case process_row(row_map, line_number, custom_field_lookup) do
@ -325,7 +327,13 @@ defmodule Mv.Membership.Import.MemberCSV do
end
end)
{:ok, %{inserted: inserted, failed: failed, errors: Enum.reverse(errors), errors_truncated?: truncated?}}
{:ok,
%{
inserted: inserted,
failed: failed,
errors: Enum.reverse(errors),
errors_truncated?: truncated?
}}
end
@doc """

View file

@ -30,6 +30,7 @@ defmodule MvWeb.MemberLive.Index do
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
require Ash.Query
require Logger
import Ash.Expr
import MvWeb.LiveHelpers, only: [current_actor: 1]
@ -43,6 +44,15 @@ defmodule MvWeb.MemberLive.Index do
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
@custom_field_prefix Mv.Constants.custom_field_prefix()
# Prefix used for boolean custom field filter URL parameters (e.g., "bf_<id>")
@boolean_filter_prefix Mv.Constants.boolean_filter_prefix()
# Maximum number of boolean custom field filters allowed per request (DoS protection)
@max_boolean_filters Mv.Constants.max_boolean_filters()
# Maximum length of UUID string (36 characters including hyphens)
@max_uuid_length Mv.Constants.max_uuid_length()
# Member fields that are loaded for the overview
# Uses constants from Mv.Constants to ensure consistency
# Note: :id is always included for member identification
@ -103,6 +113,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:cycle_status_filter, nil)
|> assign(:boolean_custom_field_filters, %{})
|> assign(:selected_members, MapSet.new())
|> assign(:settings, settings)
|> assign(:custom_fields_visible, custom_fields_visible)
@ -220,7 +231,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
new_show_current
new_show_current,
socket.assigns.boolean_custom_field_filters
)
new_path = ~p"/members?#{query_params}"
@ -334,7 +346,8 @@ defmodule MvWeb.MemberLive.Index do
existing_field_query,
existing_sort_query,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
# Set the new path with params
@ -363,7 +376,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
filter,
socket.assigns.show_current_cycle
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
new_path = ~p"/members?#{query_params}"
@ -478,6 +492,7 @@ defmodule MvWeb.MemberLive.Index do
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> maybe_update_cycle_status_filter(params)
|> maybe_update_boolean_filters(params)
|> maybe_update_show_current_cycle(params)
|> assign(:query, params["query"])
|> assign(:user_field_selection, final_selection)
@ -588,7 +603,8 @@ defmodule MvWeb.MemberLive.Index do
field_str,
Atom.to_string(order),
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
new_path = ~p"/members?#{query_params}"
@ -618,7 +634,8 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns.show_current_cycle
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
@ -636,12 +653,14 @@ defmodule MvWeb.MemberLive.Index do
# Builds URL query parameters map including all filter/sort state.
# Converts cycle_status_filter atom to string for URL.
# Adds boolean custom field filters as bf_<id>=true|false.
defp build_query_params(
query,
sort_field,
sort_order,
cycle_status_filter,
show_current_cycle
show_current_cycle,
boolean_filters
) do
field_str =
if is_atom(sort_field) do
@ -672,11 +691,19 @@ defmodule MvWeb.MemberLive.Index do
end
# Add show_current_cycle if true
if show_current_cycle do
Map.put(base_params, "show_current_cycle", "true")
else
base_params
end
base_params =
if show_current_cycle do
Map.put(base_params, "show_current_cycle", "true")
else
base_params
end
# Add boolean custom field filters
Enum.reduce(boolean_filters, base_params, fn {custom_field_id, filter_value}, acc ->
param_key = "#{@boolean_filter_prefix}#{custom_field_id}"
param_value = if filter_value == true, do: "true", else: "false"
Map.put(acc, param_key, param_value)
end)
end
# Loads members from the database with custom field values and applies search/sort/payment filters.
@ -1135,6 +1162,90 @@ defmodule MvWeb.MemberLive.Index do
defp determine_cycle_status_filter("unpaid"), do: :unpaid
defp determine_cycle_status_filter(_), do: nil
# Updates boolean custom field filters from URL parameters if present.
#
# Parses all URL parameters with prefix @boolean_filter_prefix and validates them:
# - Extracts custom field ID from parameter name (explicitly removes prefix)
# - Validates filter value using determine_boolean_filter/1
# - Whitelisting: Only custom field IDs that exist and have value_type: :boolean
# - Security: Limits to maximum @max_boolean_filters filters to prevent DoS attacks
# - Security: Validates UUID length (max @max_uuid_length characters)
#
# Returns socket with updated :boolean_custom_field_filters assign.
defp maybe_update_boolean_filters(socket, params) do
# Get all boolean custom fields for whitelisting (keyed by ID as string for consistency)
boolean_custom_fields =
socket.assigns.all_custom_fields
|> Enum.filter(&(&1.value_type == :boolean))
|> Map.new(fn cf -> {to_string(cf.id), cf} end)
# Parse all boolean filter parameters
prefix_length = String.length(@boolean_filter_prefix)
filters =
params
|> Enum.filter(fn {key, _value} -> String.starts_with?(key, @boolean_filter_prefix) end)
|> Enum.reduce(%{}, fn {key, value_str}, acc ->
# Extract custom field ID from parameter name (explicitly remove prefix)
# This is more secure than String.replace_prefix which only removes first occurrence
custom_field_id_str = String.slice(key, prefix_length, String.length(key) - prefix_length)
# Validate custom field ID length (UUIDs are max @max_uuid_length characters)
# This provides an additional security layer beyond UUID format validation
if String.length(custom_field_id_str) <= @max_uuid_length do
# Validate custom field ID exists and is boolean type
case Ecto.UUID.cast(custom_field_id_str) do
{:ok, _custom_field_id} ->
if Map.has_key?(boolean_custom_fields, custom_field_id_str) do
# Validate filter value
case determine_boolean_filter(value_str) do
nil -> acc
filter_value -> Map.put(acc, custom_field_id_str, filter_value)
end
else
acc
end
:error ->
acc
end
else
acc
end
end)
# Security: Limit number of filters to prevent DoS attacks
# Maximum @max_boolean_filters boolean filters allowed per request
filters =
if map_size(filters) > @max_boolean_filters do
Logger.warning(
"Too many boolean filters requested: #{map_size(filters)}, limiting to #{@max_boolean_filters}"
)
filters
|> Enum.take(@max_boolean_filters)
|> Enum.into(%{})
else
filters
end
assign(socket, :boolean_custom_field_filters, filters)
end
# Determines valid boolean filter value from URL parameter.
#
# SECURITY: This function whitelists allowed filter values. Only "true" and "false"
# are accepted - all other input (including malicious strings) falls back to nil.
# This ensures no raw user input is ever passed to filter functions.
#
# Returns:
# - `:true` for "true" string
# - `:false` for "false" string
# - `nil` for any other value
defp determine_boolean_filter("true"), do: true
defp determine_boolean_filter("false"), do: false
defp determine_boolean_filter(_), do: nil
# Updates show_current_cycle from URL parameters if present.
defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}) do
assign(socket, :show_current_cycle, true)