Packages

phoenix_kit

1.7.77
1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web controllers entity_form_controller.ex
Raw

lib/phoenix_kit_web/controllers/entity_form_controller.ex

defmodule PhoenixKitWeb.EntityFormController do
@moduledoc """
Controller for handling public entity form submissions.
"""
use PhoenixKitWeb, :controller
use Gettext, backend: PhoenixKitWeb.Gettext
alias PhoenixKit.Modules.Entities
alias PhoenixKit.Modules.Entities.EntityData
alias PhoenixKit.Users.RateLimiter
alias PhoenixKit.Utils.Date, as: UtilsDate
require Logger
@browser_patterns [
{"Edg/", "Edge"},
{"OPR/", "Opera"},
{"Opera", "Opera"},
{"Chrome/", "Chrome"},
{"Safari/", "Safari"},
{"Firefox/", "Firefox"},
{"MSIE", "Internet Explorer"},
{"Trident/", "Internet Explorer"}
]
@os_patterns [
{"Windows NT 10", "Windows 10"},
{"Windows NT 6.3", "Windows 8.1"},
{"Windows NT 6.2", "Windows 8"},
{"Windows NT 6.1", "Windows 7"},
{"Windows", "Windows"},
{"Mac OS X", "macOS"},
{"Macintosh", "macOS"},
{"Linux", "Linux"},
{"Android", "Android"},
{"iPhone", "iOS"},
{"iPad", "iOS"}
]
@device_patterns [
{"Mobile", "mobile"},
{"Android", "mobile"},
{"iPhone", "mobile"},
{"iPad", "tablet"},
{"Tablet", "tablet"}
]
# Minimum time in seconds for form submission (time-based validation)
@min_submission_time 3
# Rate limit: max submissions per IP
@rate_limit_max 5
@rate_limit_window_ms 60_000
@doc """
Handles public form submission for entities.
"""
def submit(conn, %{"entity_slug" => entity_slug} = params) do
entity = Entities.get_entity_by_name(entity_slug)
cond do
is_nil(entity) ->
conn
|> put_flash(:error, gettext("Entity not found"))
|> redirect_back(conn)
!public_form_enabled?(entity) ->
conn
|> put_flash(:error, gettext("Public form is not enabled for this entity"))
|> redirect_back(conn)
true ->
# Run security checks and collect any flags
security_result = run_security_checks(conn, entity, params)
handle_security_result(conn, entity, params, security_result)
end
end
defp run_security_checks(conn, entity, params) do
settings = entity.settings || %{}
# Collect all security check results
checks = [
check_honeypot(settings, params),
check_submission_time(settings, params),
check_rate_limit(conn, settings, entity)
]
# Find any triggered checks that require action
triggered =
checks
|> Enum.filter(fn
{:triggered, _type, _action} -> true
_ -> false
end)
case triggered do
[] -> :ok
flags -> {:flagged, flags}
end
end
defp handle_security_result(conn, entity, params, :ok) do
handle_submission(conn, entity, params, [])
end
defp handle_security_result(conn, entity, params, {:flagged, flags}) do
# Check if any flags require rejection
reject_flags =
Enum.filter(flags, fn {:triggered, _type, action} ->
action in ["reject_silent", "reject_error"]
end)
save_flags =
Enum.filter(flags, fn {:triggered, _type, action} ->
action in ["save_suspicious", "save_log"]
end)
cond do
# If any flag requires rejection
not Enum.empty?(reject_flags) ->
handle_rejection(conn, entity, reject_flags)
# If flags only require saving with markers
not Enum.empty?(save_flags) ->
handle_submission(conn, entity, params, save_flags)
# Fallback - should not happen
true ->
handle_submission(conn, entity, params, [])
end
end
defp handle_rejection(conn, entity, reject_flags) do
settings = entity.settings || %{}
debug_mode = Map.get(settings, "public_form_debug_mode", false)
# Track rejected submission
increment_form_stats(entity, :rejected, reject_flags)
# Check if any require error message (reject_error takes precedence)
has_error =
Enum.any?(reject_flags, fn {:triggered, _type, action} ->
action == "reject_error"
end)
if has_error do
# Get the first error type for the message
{:triggered, error_type, _} = List.first(reject_flags)
error_message = get_error_message(error_type, debug_mode)
conn
|> put_flash(:error, error_message)
|> redirect_back(conn)
else
# Silent rejection - show fake success
conn
|> put_flash(:info, get_success_message(entity))
|> redirect_back(conn)
end
end
# Debug mode error messages (detailed)
defp get_error_message(:honeypot, true),
do: gettext("[Debug] Submission rejected: Honeypot field was filled.")
defp get_error_message(:too_fast, true),
do:
gettext(
"[Debug] Submission rejected: Form was submitted too quickly (less than 3 seconds)."
)
defp get_error_message(:rate_limited, true),
do: gettext("[Debug] Submission rejected: Rate limit exceeded (5 submissions per minute).")
defp get_error_message(type, true),
do: gettext("[Debug] Submission rejected: Security check failed (%{type}).", type: type)
# Normal error messages (generic)
defp get_error_message(:honeypot, false), do: gettext("There was an error submitting the form.")
defp get_error_message(:too_fast, false),
do: gettext("Please take your time filling out the form.")
defp get_error_message(:rate_limited, false),
do: gettext("Too many submissions. Please try again later.")
defp get_error_message(_, false), do: gettext("There was an error submitting the form.")
defp check_honeypot(settings, params) do
if Map.get(settings, "public_form_honeypot", false) do
honeypot_value = Map.get(params, "_hp_website", "")
if honeypot_value == "" do
:ok
else
action = Map.get(settings, "public_form_honeypot_action", "reject_silent")
{:triggered, :honeypot, action}
end
else
:ok
end
end
defp check_submission_time(settings, params) do
if Map.get(settings, "public_form_time_check", false) do
case get_time_to_submit(params) do
nil ->
# No timestamp provided, allow (could be form cached before feature enabled)
:ok
seconds when seconds >= @min_submission_time ->
:ok
_too_fast ->
action = Map.get(settings, "public_form_time_check_action", "reject_error")
{:triggered, :too_fast, action}
end
else
:ok
end
end
defp get_time_to_submit(params) do
case Map.get(params, "_form_loaded_at") do
nil ->
nil
loaded_at_str ->
case DateTime.from_iso8601(loaded_at_str) do
{:ok, loaded_at, _offset} ->
DateTime.diff(UtilsDate.utc_now(), loaded_at, :second)
_ ->
nil
end
end
end
defp check_rate_limit(conn, settings, entity) do
if Map.get(settings, "public_form_rate_limit", false) do
ip = get_client_ip(conn)
key = "entity_form:#{entity.id}:#{ip}"
# Use the same Backend module used by RateLimiter
case RateLimiter.Backend.hit(key, @rate_limit_window_ms, @rate_limit_max) do
{:allow, _count} ->
:ok
{:deny, _retry_after} ->
action = Map.get(settings, "public_form_rate_limit_action", "reject_error")
{:triggered, :rate_limited, action}
end
else
:ok
end
rescue
# If Hammer is not available or not started, allow the request
_ -> :ok
end
defp get_success_message(entity) do
settings = entity.settings || %{}
Map.get(settings, "public_form_success_message", gettext("Form submitted successfully!"))
end
defp apply_security_flags(metadata, [], _logger) do
{metadata, "published"}
end
defp apply_security_flags(metadata, security_flags, logger) do
# Build list of security warnings
warnings =
Enum.map(security_flags, fn {:triggered, type, action} ->
%{"type" => Atom.to_string(type), "action" => action}
end)
# Check if any flag marks as suspicious
is_suspicious =
Enum.any?(security_flags, fn {:triggered, _type, action} ->
action == "save_suspicious"
end)
# Check if any flag requires logging
should_log =
Enum.any?(security_flags, fn {:triggered, _type, action} ->
action == "save_log"
end)
# Log warnings if needed
if should_log do
flag_types = Enum.map(security_flags, fn {:triggered, type, _} -> type end)
logger.warning("Form submission with security flags: #{inspect(flag_types)}")
end
# Add warnings to metadata
metadata = Map.put(metadata, "security_warnings", warnings)
# Set status based on flags
status = if is_suspicious, do: "draft", else: "published"
{metadata, status}
end
defp handle_submission(conn, entity, params, security_flags) do
# Extract form data from params
form_data = get_in(params, ["phoenix_kit_entity_data", "data"]) || %{}
# Filter to only include allowed public form fields
settings = entity.settings || %{}
allowed_fields = Map.get(settings, "public_form_fields", [])
filtered_data =
form_data
|> Enum.filter(fn {key, _value} -> key in allowed_fields end)
|> Enum.into(%{})
# Build entity data params
# For public submissions, use the current user if logged in, otherwise nil (system)
current_user = conn.assigns[:current_user]
created_by_uuid = if current_user, do: current_user.uuid, else: nil
title = generate_submission_title(entity, filtered_data)
# Capture submission metadata if enabled (default is true)
collect_metadata = Map.get(settings, "public_form_collect_metadata") != false
metadata =
if collect_metadata,
do: build_submission_metadata(conn, params),
else: %{"source" => "public_form"}
# Add security flags to metadata if any were triggered
{metadata, status} = apply_security_flags(metadata, security_flags, Logger)
entity_data_params = %{
"entity_uuid" => entity.id,
"title" => title,
"slug" => generate_slug(title),
"status" => status,
"data" => filtered_data,
"metadata" => metadata,
"created_by_uuid" => created_by_uuid
}
case EntityData.create(entity_data_params) do
{:ok, _data_record} ->
# Track successful submission
increment_form_stats(entity, :submitted, security_flags)
success_message =
Map.get(
settings,
"public_form_success_message",
gettext("Form submitted successfully!")
)
conn
|> put_flash(:info, success_message)
|> redirect_back(conn)
{:error, _changeset} ->
conn
|> put_flash(:error, gettext("There was an error submitting the form. Please try again."))
|> redirect_back(conn)
end
end
defp public_form_enabled?(entity) do
settings = entity.settings || %{}
enabled = Map.get(settings, "public_form_enabled", false)
fields = Map.get(settings, "public_form_fields", [])
# Form is only truly enabled if it's enabled AND has at least one field selected
enabled && not Enum.empty?(fields)
end
defp generate_submission_title(entity, data) do
# Try to use a meaningful field value as title, or use entity display name
title_candidates = ["name", "title", "subject", "email"]
Enum.find_value(title_candidates, fn field ->
value = Map.get(data, field)
if value && value != "", do: value
end) || entity.display_name
end
defp generate_slug(title) do
title
|> String.downcase()
|> String.replace(~r/[^a-z0-9\s-]/, "")
|> String.replace(~r/\s+/, "-")
|> String.trim("-")
|> Kernel.<>("-#{:rand.uniform(9999)}")
end
defp build_submission_metadata(conn, params) do
user_agent = get_req_header(conn, "user-agent") |> List.first() || ""
referer = get_req_header(conn, "referer") |> List.first()
submitted_at = UtilsDate.utc_now()
# Get form timing data
form_loaded_at = Map.get(params, "_form_loaded_at")
time_to_submit = get_time_to_submit(params)
%{
"source" => "public_form",
"ip_address" => get_client_ip(conn),
"user_agent" => user_agent,
"browser" => parse_browser(user_agent),
"os" => parse_os(user_agent),
"device" => parse_device(user_agent),
"referer" => referer,
"form_loaded_at" => form_loaded_at,
"submitted_at" => DateTime.to_iso8601(submitted_at),
"time_to_submit_seconds" => time_to_submit
}
end
defp get_client_ip(conn) do
# Check for forwarded IP (behind proxy/load balancer)
forwarded_for = get_req_header(conn, "x-forwarded-for") |> List.first()
if forwarded_for do
forwarded_for
|> String.split(",")
|> List.first()
|> String.trim()
else
conn.remote_ip
|> :inet.ntoa()
|> to_string()
end
end
defp parse_browser(user_agent) do
Enum.find_value(@browser_patterns, "Unknown", fn {pattern, name} ->
if String.contains?(user_agent, pattern), do: name
end)
end
defp parse_os(user_agent) do
Enum.find_value(@os_patterns, "Unknown", fn {pattern, name} ->
if String.contains?(user_agent, pattern), do: name
end)
end
defp parse_device(user_agent) do
Enum.find_value(@device_patterns, "desktop", fn {pattern, type} ->
if String.contains?(user_agent, pattern), do: type
end)
end
defp redirect_back(conn, _fallback_conn) do
referer = get_req_header(conn, "referer") |> List.first()
if referer do
redirect(conn, external: referer)
else
redirect(conn, to: "/")
end
end
# Form statistics tracking
# Stats are stored in entity.settings under "public_form_stats"
defp increment_form_stats(entity, event_type, security_flags) do
# Run async to not block the response
Task.start(fn ->
try do
current_settings = entity.settings || %{}
current_stats = Map.get(current_settings, "public_form_stats", %{})
# Initialize stats structure if needed
updated_stats =
current_stats
|> Map.update("total_submissions", 1, &(&1 + 1))
|> update_event_count(event_type)
|> update_security_stats(security_flags)
|> Map.put("last_submission_at", UtilsDate.utc_now() |> DateTime.to_iso8601())
updated_settings = Map.put(current_settings, "public_form_stats", updated_stats)
# Update entity settings directly via Repo
Entities.update_entity(entity, %{"settings" => updated_settings})
rescue
_ -> :ok
end
end)
end
defp update_event_count(stats, :submitted) do
Map.update(stats, "successful_submissions", 1, &(&1 + 1))
end
defp update_event_count(stats, :rejected) do
Map.update(stats, "rejected_submissions", 1, &(&1 + 1))
end
defp update_security_stats(stats, []), do: stats
defp update_security_stats(stats, security_flags) do
Enum.reduce(security_flags, stats, fn {:triggered, type, _action}, acc ->
key = "#{type}_triggers"
Map.update(acc, key, 1, &(&1 + 1))
end)
end
end