fix: address security vulnerabilities and concurrency issues
- Fix SQL injection in partition_manager, db_optimizer, and release.ex - Fix XSS vulnerabilities with proper HTML escaping in LiveViews - Add proper error handling for email delivery functions - Fix race conditions with advisory locks and atomic operations - Replace unsupervised spawn/Task.start with supervised alternatives - Convert ETS operations to GenServer serialization for thread safety - Change ETS tables from :public to :protected access - Add client limits to prevent unbounded memory growth - Add PubSub cleanup in GenServer terminate callbacks - Fix device upsert to use atomic Repo.insert_all
This commit is contained in:
parent
4477fdf615
commit
288b9fbbb2
22 changed files with 359 additions and 208 deletions
|
|
@ -27,6 +27,8 @@ defmodule Aprsme.Application do
|
||||||
Aprsme.CircuitBreaker,
|
Aprsme.CircuitBreaker,
|
||||||
# Start regex cache for performance
|
# Start regex cache for performance
|
||||||
Aprsme.RegexCache,
|
Aprsme.RegexCache,
|
||||||
|
# Start cache manager
|
||||||
|
Aprsme.Cache,
|
||||||
# Start device cache manager
|
# Start device cache manager
|
||||||
Aprsme.DeviceCache,
|
Aprsme.DeviceCache,
|
||||||
# Start broadcast task supervisor for async operations
|
# Start broadcast task supervisor for async operations
|
||||||
|
|
@ -88,7 +90,9 @@ defmodule Aprsme.Application do
|
||||||
env = Application.get_env(:aprsme, :env)
|
env = Application.get_env(:aprsme, :env)
|
||||||
|
|
||||||
if env != :test do
|
if env != :test do
|
||||||
Task.start(fn -> Aprsme.DeviceIdentification.maybe_refresh_devices() end)
|
Aprsme.BroadcastTaskSupervisor.async_execute(fn ->
|
||||||
|
Aprsme.DeviceIdentification.maybe_refresh_devices()
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:ok, sup}
|
{:ok, sup}
|
||||||
|
|
@ -138,10 +142,6 @@ defmodule Aprsme.Application do
|
||||||
cluster_children =
|
cluster_children =
|
||||||
if topologies == [] do
|
if topologies == [] do
|
||||||
[]
|
[]
|
||||||
# libcluster supervisor
|
|
||||||
# Dynamic supervisor for processes managed by cluster leader
|
|
||||||
# Leader election process
|
|
||||||
# Connection manager that starts/stops APRS-IS based on leadership
|
|
||||||
else
|
else
|
||||||
[
|
[
|
||||||
{Cluster.Supervisor, [topologies, [name: Aprsme.ClusterSupervisor]]},
|
{Cluster.Supervisor, [topologies, [name: Aprsme.ClusterSupervisor]]},
|
||||||
|
|
@ -149,6 +149,7 @@ defmodule Aprsme.Application do
|
||||||
Aprsme.Cluster.LeaderElection,
|
Aprsme.Cluster.LeaderElection,
|
||||||
Aprsme.Cluster.ConnectionManager,
|
Aprsme.Cluster.ConnectionManager,
|
||||||
Aprsme.Cluster.PacketReceiver,
|
Aprsme.Cluster.PacketReceiver,
|
||||||
|
Aprsme.Cluster.PacketDistributor,
|
||||||
Aprsme.ConnectionMonitor
|
Aprsme.ConnectionMonitor
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
@ -189,10 +190,10 @@ defmodule Aprsme.Application do
|
||||||
Logger.info("Starting ETS-based caching and rate limiting")
|
Logger.info("Starting ETS-based caching and rate limiting")
|
||||||
|
|
||||||
# Create ETS tables for caching
|
# Create ETS tables for caching
|
||||||
:ets.new(:query_cache, [:set, :public, :named_table, read_concurrency: true])
|
:ets.new(:query_cache, [:set, :protected, :named_table, read_concurrency: true])
|
||||||
:ets.new(:device_cache, [:set, :public, :named_table, read_concurrency: true])
|
:ets.new(:device_cache, [:set, :protected, :named_table, read_concurrency: true])
|
||||||
:ets.new(:symbol_cache, [:set, :public, :named_table, read_concurrency: true])
|
:ets.new(:symbol_cache, [:set, :protected, :named_table, read_concurrency: true])
|
||||||
:ets.new(:aprsme, [:set, :public, :named_table, read_concurrency: true])
|
:ets.new(:aprsme, [:set, :protected, :named_table, read_concurrency: true])
|
||||||
:ets.insert(:aprsme, {:message_number, 0})
|
:ets.insert(:aprsme, {:message_number, 0})
|
||||||
|
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -8,31 +8,22 @@ defmodule Aprsme.Cache do
|
||||||
Expired entries are lazily evicted on read.
|
Expired entries are lazily evicted on read.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
|
||||||
|
def start_link(opts) do
|
||||||
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(_opts) do
|
||||||
|
{:ok, %{}}
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Get a value from cache. Returns `{:ok, nil}` for expired entries.
|
Get a value from cache. Returns `{:ok, nil}` for expired entries.
|
||||||
"""
|
"""
|
||||||
def get(cache_name, key) do
|
def get(cache_name, key) do
|
||||||
case :ets.lookup(cache_name, key) do
|
GenServer.call(__MODULE__, {:get, cache_name, key})
|
||||||
[{^key, value, :infinity}] ->
|
|
||||||
{:ok, value}
|
|
||||||
|
|
||||||
[{^key, value, expires_at}] ->
|
|
||||||
if System.monotonic_time(:millisecond) < expires_at do
|
|
||||||
{:ok, value}
|
|
||||||
else
|
|
||||||
:ets.delete(cache_name, key)
|
|
||||||
{:ok, nil}
|
|
||||||
end
|
|
||||||
|
|
||||||
# Support legacy {key, value} tuples during transition
|
|
||||||
[{^key, value}] ->
|
|
||||||
{:ok, value}
|
|
||||||
|
|
||||||
[] ->
|
|
||||||
{:ok, nil}
|
|
||||||
end
|
|
||||||
rescue
|
|
||||||
ArgumentError -> {:error, :no_cache}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -42,47 +33,28 @@ defmodule Aprsme.Cache do
|
||||||
* `:ttl` - Time to live in milliseconds. Use `Cache.to_timeout/1` for convenience.
|
* `:ttl` - Time to live in milliseconds. Use `Cache.to_timeout/1` for convenience.
|
||||||
"""
|
"""
|
||||||
def put(cache_name, key, value, opts \\ []) do
|
def put(cache_name, key, value, opts \\ []) do
|
||||||
expires_at =
|
GenServer.call(__MODULE__, {:put, cache_name, key, value, opts})
|
||||||
case Keyword.get(opts, :ttl) do
|
|
||||||
nil -> :infinity
|
|
||||||
ttl when is_integer(ttl) and ttl > 0 -> System.monotonic_time(:millisecond) + ttl
|
|
||||||
_ -> :infinity
|
|
||||||
end
|
|
||||||
|
|
||||||
:ets.insert(cache_name, {key, value, expires_at})
|
|
||||||
{:ok, true}
|
|
||||||
rescue
|
|
||||||
ArgumentError -> {:error, :no_cache}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Delete a key from cache
|
Delete a key from cache
|
||||||
"""
|
"""
|
||||||
def del(cache_name, key) do
|
def del(cache_name, key) do
|
||||||
:ets.delete(cache_name, key)
|
GenServer.call(__MODULE__, {:del, cache_name, key})
|
||||||
{:ok, true}
|
|
||||||
rescue
|
|
||||||
ArgumentError -> {:error, :no_cache}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Clear all keys from cache
|
Clear all keys from cache
|
||||||
"""
|
"""
|
||||||
def clear(cache_name) do
|
def clear(cache_name) do
|
||||||
:ets.delete_all_objects(cache_name)
|
GenServer.call(__MODULE__, {:clear, cache_name})
|
||||||
{:ok, true}
|
|
||||||
rescue
|
|
||||||
ArgumentError -> {:error, :no_cache}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Get cache statistics (simplified for ETS)
|
Get cache statistics (simplified for ETS)
|
||||||
"""
|
"""
|
||||||
def stats(cache_name) do
|
def stats(cache_name) do
|
||||||
info = :ets.info(cache_name)
|
GenServer.call(__MODULE__, {:stats, cache_name})
|
||||||
{:ok, %{size: Keyword.get(info, :size, 0)}}
|
|
||||||
rescue
|
|
||||||
ArgumentError -> {:error, :no_cache}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -100,14 +72,7 @@ defmodule Aprsme.Cache do
|
||||||
Get TTL for a key. Returns remaining milliseconds or nil if no TTL.
|
Get TTL for a key. Returns remaining milliseconds or nil if no TTL.
|
||||||
"""
|
"""
|
||||||
def ttl(cache_name, key) do
|
def ttl(cache_name, key) do
|
||||||
case :ets.lookup(cache_name, key) do
|
GenServer.call(__MODULE__, {:ttl, cache_name, key})
|
||||||
[{^key, _value, :infinity}] -> {:ok, nil}
|
|
||||||
[{^key, _value, expires_at}] -> {:ok, max(0, expires_at - System.monotonic_time(:millisecond))}
|
|
||||||
[{^key, _value}] -> {:ok, nil}
|
|
||||||
[] -> {:ok, nil}
|
|
||||||
end
|
|
||||||
rescue
|
|
||||||
ArgumentError -> {:ok, nil}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -127,4 +92,110 @@ defmodule Aprsme.Cache do
|
||||||
{:milliseconds, n}, acc -> acc + n
|
{:milliseconds, n}, acc -> acc + n
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:get, cache_name, key}, _from, state) do
|
||||||
|
result =
|
||||||
|
try do
|
||||||
|
case :ets.lookup(cache_name, key) do
|
||||||
|
[{^key, value, :infinity}] ->
|
||||||
|
{:ok, value}
|
||||||
|
|
||||||
|
[{^key, value, expires_at}] ->
|
||||||
|
if System.monotonic_time(:millisecond) < expires_at do
|
||||||
|
{:ok, value}
|
||||||
|
else
|
||||||
|
:ets.delete(cache_name, key)
|
||||||
|
{:ok, nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
[{^key, value}] ->
|
||||||
|
{:ok, value}
|
||||||
|
|
||||||
|
[] ->
|
||||||
|
{:ok, nil}
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
ArgumentError ->
|
||||||
|
{:error, :no_cache}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:reply, result, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:put, cache_name, key, value, opts}, _from, state) do
|
||||||
|
result =
|
||||||
|
try do
|
||||||
|
expires_at =
|
||||||
|
case Keyword.get(opts, :ttl) do
|
||||||
|
nil -> :infinity
|
||||||
|
ttl when is_integer(ttl) and ttl > 0 -> System.monotonic_time(:millisecond) + ttl
|
||||||
|
_ -> :infinity
|
||||||
|
end
|
||||||
|
|
||||||
|
:ets.insert(cache_name, {key, value, expires_at})
|
||||||
|
{:ok, true}
|
||||||
|
rescue
|
||||||
|
ArgumentError -> {:error, :no_cache}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:reply, result, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:del, cache_name, key}, _from, state) do
|
||||||
|
result =
|
||||||
|
try do
|
||||||
|
:ets.delete(cache_name, key)
|
||||||
|
{:ok, true}
|
||||||
|
rescue
|
||||||
|
ArgumentError -> {:error, :no_cache}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:reply, result, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:clear, cache_name}, _from, state) do
|
||||||
|
result =
|
||||||
|
try do
|
||||||
|
:ets.delete_all_objects(cache_name)
|
||||||
|
{:ok, true}
|
||||||
|
rescue
|
||||||
|
ArgumentError -> {:error, :no_cache}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:reply, result, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:stats, cache_name}, _from, state) do
|
||||||
|
result =
|
||||||
|
try do
|
||||||
|
info = :ets.info(cache_name)
|
||||||
|
{:ok, %{size: Keyword.get(info, :size, 0)}}
|
||||||
|
rescue
|
||||||
|
ArgumentError -> {:error, :no_cache}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:reply, result, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:ttl, cache_name, key}, _from, state) do
|
||||||
|
result =
|
||||||
|
try do
|
||||||
|
case :ets.lookup(cache_name, key) do
|
||||||
|
[{^key, _value, :infinity}] -> {:ok, nil}
|
||||||
|
[{^key, _value, expires_at}] -> {:ok, max(0, expires_at - System.monotonic_time(:millisecond))}
|
||||||
|
[{^key, _value}] -> {:ok, nil}
|
||||||
|
[] -> {:ok, nil}
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
ArgumentError -> {:ok, nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:reply, result, state}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,12 @@ defmodule Aprsme.Cluster.ConnectionManager do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def terminate(_reason, _state) do
|
||||||
|
Phoenix.PubSub.unsubscribe(Aprsme.PubSub, "cluster:leadership")
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
defp leader_check do
|
defp leader_check do
|
||||||
LeaderElection.leader?()
|
LeaderElection.leader?()
|
||||||
catch
|
catch
|
||||||
|
|
|
||||||
|
|
@ -123,12 +123,8 @@ defmodule Aprsme.Cluster.LeaderElection do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info(:attempt_election, state) do
|
def handle_info(:attempt_election, state) do
|
||||||
# First, try to clean up any stale registrations
|
|
||||||
cleanup_stale_registrations()
|
cleanup_stale_registrations()
|
||||||
|
|
||||||
# Check if we already hold the registration — re-registering the same
|
|
||||||
# name returns :no even for the same PID, which would incorrectly clear
|
|
||||||
# our leadership state.
|
|
||||||
case :global.whereis_name(@election_key) do
|
case :global.whereis_name(@election_key) do
|
||||||
pid when pid == self() ->
|
pid when pid == self() ->
|
||||||
if !state.is_leader do
|
if !state.is_leader do
|
||||||
|
|
@ -140,7 +136,7 @@ defmodule Aprsme.Cluster.LeaderElection do
|
||||||
{:noreply, %{state | is_leader: true, leader_node: node()}}
|
{:noreply, %{state | is_leader: true, leader_node: node()}}
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
attempt_registration(state)
|
attempt_registration_atomic(state)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -205,8 +201,23 @@ defmodule Aprsme.Cluster.LeaderElection do
|
||||||
|
|
||||||
defp verify_leadership(state), do: state
|
defp verify_leadership(state), do: state
|
||||||
|
|
||||||
defp attempt_registration(state) do
|
defp attempt_registration_atomic(state) do
|
||||||
case :global.register_name(@election_key, self(), &resolve_conflict/3) do
|
result =
|
||||||
|
:global.trans({@election_key, node()}, fn ->
|
||||||
|
case :global.whereis_name(@election_key) do
|
||||||
|
:undefined ->
|
||||||
|
:global.register_name(@election_key, self(), &resolve_conflict/3)
|
||||||
|
|
||||||
|
pid ->
|
||||||
|
if pid == self() do
|
||||||
|
:yes
|
||||||
|
else
|
||||||
|
:no
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
case result do
|
||||||
:yes ->
|
:yes ->
|
||||||
Logger.info("Elected as APRS-IS connection leader on node #{node()}")
|
Logger.info("Elected as APRS-IS connection leader on node #{node()}")
|
||||||
:persistent_term.put({__MODULE__, :is_leader}, true)
|
:persistent_term.put({__MODULE__, :is_leader}, true)
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,20 @@ defmodule Aprsme.Cluster.PacketDistributor do
|
||||||
This ensures all nodes can serve real-time updates via LiveView while
|
This ensures all nodes can serve real-time updates via LiveView while
|
||||||
only the leader maintains the APRS-IS connection.
|
only the leader maintains the APRS-IS connection.
|
||||||
"""
|
"""
|
||||||
|
use GenServer
|
||||||
|
|
||||||
alias Aprsme.Cluster.LeaderElection
|
alias Aprsme.Cluster.LeaderElection
|
||||||
|
|
||||||
@pubsub_topic "cluster:packets"
|
@pubsub_topic "cluster:packets"
|
||||||
|
|
||||||
|
def start_link(opts) do
|
||||||
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
def distribute_packet(packet) do
|
def distribute_packet(packet) do
|
||||||
# Only distribute if clustering is enabled and we're the leader
|
|
||||||
cluster_enabled = Application.get_env(:aprsme, :cluster_enabled, false)
|
cluster_enabled = Application.get_env(:aprsme, :cluster_enabled, false)
|
||||||
|
|
||||||
if cluster_enabled and LeaderElection.leader_cached?() do
|
if cluster_enabled and LeaderElection.leader_cached?() do
|
||||||
# Broadcast to all nodes including self
|
|
||||||
Phoenix.PubSub.broadcast(
|
Phoenix.PubSub.broadcast(
|
||||||
Aprsme.PubSub,
|
Aprsme.PubSub,
|
||||||
@pubsub_topic,
|
@pubsub_topic,
|
||||||
|
|
@ -27,10 +31,21 @@ defmodule Aprsme.Cluster.PacketDistributor do
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_distributed_packet({:distributed_packet, packet}) do
|
def handle_distributed_packet({:distributed_packet, packet}) do
|
||||||
# Broadcast to local LiveView clients via both PubSub systems
|
|
||||||
Aprsme.StreamingPacketsPubSub.broadcast_packet(packet)
|
Aprsme.StreamingPacketsPubSub.broadcast_packet(packet)
|
||||||
Aprsme.SpatialPubSub.broadcast_packet(packet)
|
Aprsme.SpatialPubSub.broadcast_packet(packet)
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(_opts) do
|
||||||
|
Phoenix.PubSub.subscribe(Aprsme.PubSub, @pubsub_topic)
|
||||||
|
{:ok, %{}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def terminate(_reason, _state) do
|
||||||
|
Phoenix.PubSub.unsubscribe(Aprsme.PubSub, @pubsub_topic)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,8 @@ defmodule Aprsme.DbOptimizer do
|
||||||
"""
|
"""
|
||||||
def analyze_table(table_name) do
|
def analyze_table(table_name) do
|
||||||
validate_identifier!(table_name)
|
validate_identifier!(table_name)
|
||||||
SQL.query!(Repo, "ANALYZE #{table_name}", [])
|
quoted_name = quote_identifier(table_name)
|
||||||
|
SQL.query!(Repo, "ANALYZE #{quoted_name}", [])
|
||||||
:ok
|
:ok
|
||||||
rescue
|
rescue
|
||||||
error ->
|
error ->
|
||||||
|
|
@ -90,15 +91,15 @@ defmodule Aprsme.DbOptimizer do
|
||||||
"""
|
"""
|
||||||
def vacuum_table(table_name, opts \\ []) do
|
def vacuum_table(table_name, opts \\ []) do
|
||||||
validate_identifier!(table_name)
|
validate_identifier!(table_name)
|
||||||
|
quoted_name = quote_identifier(table_name)
|
||||||
full = Keyword.get(opts, :full, false)
|
full = Keyword.get(opts, :full, false)
|
||||||
analyze = Keyword.get(opts, :analyze, true)
|
analyze = Keyword.get(opts, :analyze, true)
|
||||||
|
|
||||||
vacuum_type = if full, do: "VACUUM FULL", else: "VACUUM"
|
vacuum_type = if full, do: "VACUUM FULL", else: "VACUUM"
|
||||||
analyze_clause = if analyze, do: " ANALYZE", else: ""
|
analyze_clause = if analyze, do: " ANALYZE", else: ""
|
||||||
|
|
||||||
query = "#{vacuum_type}#{analyze_clause} #{table_name}"
|
query = "#{vacuum_type}#{analyze_clause} #{quoted_name}"
|
||||||
|
|
||||||
# Vacuum operations can take a long time
|
|
||||||
SQL.query!(Repo, query, [], timeout: :infinity)
|
SQL.query!(Repo, query, [], timeout: :infinity)
|
||||||
:ok
|
:ok
|
||||||
rescue
|
rescue
|
||||||
|
|
@ -147,7 +148,12 @@ defmodule Aprsme.DbOptimizer do
|
||||||
|
|
||||||
defp validate_identifier!(name) when is_atom(name), do: validate_identifier!(Atom.to_string(name))
|
defp validate_identifier!(name) when is_atom(name), do: validate_identifier!(Atom.to_string(name))
|
||||||
|
|
||||||
# Default 1KB
|
defp quote_identifier(name) when is_binary(name) do
|
||||||
|
~s("#{String.replace(name, ~s("), ~s(""))}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp quote_identifier(name) when is_atom(name), do: quote_identifier(Atom.to_string(name))
|
||||||
|
|
||||||
defp estimate_entry_size(nil), do: 1024
|
defp estimate_entry_size(nil), do: 1024
|
||||||
|
|
||||||
defp estimate_entry_size(entry) when is_map(entry) do
|
defp estimate_entry_size(entry) when is_map(entry) do
|
||||||
|
|
|
||||||
|
|
@ -161,24 +161,22 @@ defmodule Aprsme.DeviceIdentification do
|
||||||
micelegacy = Map.get(json, "micelegacy", %{})
|
micelegacy = Map.get(json, "micelegacy", %{})
|
||||||
now = DateTime.utc_now()
|
now = DateTime.utc_now()
|
||||||
|
|
||||||
Repo.transaction(fn ->
|
all_devices =
|
||||||
Repo.delete_all(Devices)
|
Enum.flat_map([tocalls, mice, micelegacy], fn group ->
|
||||||
|
Enum.map(group, fn {identifier, attrs} ->
|
||||||
Enum.each([tocalls, mice, micelegacy], fn group ->
|
process_device_attrs(attrs, identifier, now)
|
||||||
upsert_device_group(group, now)
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok, _result} =
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
Repo.delete_all(Devices)
|
||||||
|
Repo.insert_all(Devices, all_devices)
|
||||||
end)
|
end)
|
||||||
end)
|
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
defp upsert_device_group(group, now) do
|
|
||||||
Enum.each(group, fn {identifier, attrs} ->
|
|
||||||
processed_attrs = process_device_attrs(attrs, identifier, now)
|
|
||||||
%Devices{} |> Devices.changeset(processed_attrs) |> Repo.insert!()
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp process_device_attrs(attrs, identifier, now) do
|
defp process_device_attrs(attrs, identifier, now) do
|
||||||
attrs
|
attrs
|
||||||
|> Map.put("identifier", identifier)
|
|> Map.put("identifier", identifier)
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,7 @@ defmodule Aprsme.Packets.PreparedQueries do
|
||||||
|
|
||||||
# Convert miles to meters for PostGIS (1 mile = 1609.34 meters)
|
# Convert miles to meters for PostGIS (1 mile = 1609.34 meters)
|
||||||
radius_meters = radius_miles * 1609.34
|
radius_meters = radius_miles * 1609.34
|
||||||
|
|
||||||
cutoff_time =
|
cutoff_time =
|
||||||
DateTime.utc_now()
|
DateTime.utc_now()
|
||||||
|> DateTime.truncate(:second)
|
|> DateTime.truncate(:second)
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ defmodule Aprsme.PartitionManager do
|
||||||
if partition_exists?(name) do
|
if partition_exists?(name) do
|
||||||
acc
|
acc
|
||||||
else
|
else
|
||||||
create_partition(date)
|
create_partition_with_lock(date)
|
||||||
[name | acc]
|
[name | acc]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -64,6 +64,21 @@ defmodule Aprsme.PartitionManager do
|
||||||
{:ok, Enum.reverse(created)}
|
{:ok, Enum.reverse(created)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp create_partition_with_lock(%Date{} = date) do
|
||||||
|
name = partition_name(date)
|
||||||
|
lock_key = :erlang.phash2(name)
|
||||||
|
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
Repo.query!("SELECT pg_advisory_xact_lock($1)", [lock_key])
|
||||||
|
|
||||||
|
if !partition_exists?(name) do
|
||||||
|
create_partition(date)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
name
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Drops partitions older than the given number of retention days.
|
Drops partitions older than the given number of retention days.
|
||||||
Returns {:ok, list_of_dropped_partition_names}.
|
Returns {:ok, list_of_dropped_partition_names}.
|
||||||
|
|
@ -164,8 +179,10 @@ defmodule Aprsme.PartitionManager do
|
||||||
from_str = DateTime.to_iso8601(from_dt)
|
from_str = DateTime.to_iso8601(from_dt)
|
||||||
to_str = DateTime.to_iso8601(to_dt)
|
to_str = DateTime.to_iso8601(to_dt)
|
||||||
|
|
||||||
|
validate_partition_name!(name)
|
||||||
|
|
||||||
Repo.query!(
|
Repo.query!(
|
||||||
"CREATE TABLE IF NOT EXISTS #{name} PARTITION OF packets FOR VALUES FROM ('#{from_str}') TO ('#{to_str}')"
|
"CREATE TABLE IF NOT EXISTS #{quote_identifier(name)} PARTITION OF packets FOR VALUES FROM ('#{from_str}') TO ('#{to_str}')"
|
||||||
)
|
)
|
||||||
|
|
||||||
Logger.debug("Created partition #{name} [#{from_str}, #{to_str})")
|
Logger.debug("Created partition #{name} [#{from_str}, #{to_str})")
|
||||||
|
|
@ -173,11 +190,27 @@ defmodule Aprsme.PartitionManager do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp drop_partition(name) do
|
defp drop_partition(name) do
|
||||||
Repo.query!("DROP TABLE IF EXISTS #{name}")
|
# Validate partition name to prevent SQL injection
|
||||||
|
validate_partition_name!(name)
|
||||||
|
|
||||||
|
Repo.query!("DROP TABLE IF EXISTS #{quote_identifier(name)}")
|
||||||
Logger.debug("Dropped partition #{name}")
|
Logger.debug("Dropped partition #{name}")
|
||||||
name
|
name
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Validates that partition name matches expected format: packets_YYYYMMDD
|
||||||
|
# Raises if name is invalid to prevent SQL injection
|
||||||
|
defp validate_partition_name!(name) do
|
||||||
|
if !String.match?(name, ~r/^packets_\d{8}$/) do
|
||||||
|
raise ArgumentError, "Invalid partition name: #{inspect(name)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Quotes SQL identifier to prevent injection
|
||||||
|
defp quote_identifier(name) do
|
||||||
|
~s("#{String.replace(name, ~s("), ~s(""))}")
|
||||||
|
end
|
||||||
|
|
||||||
defp partition_date("packets_" <> date_str) do
|
defp partition_date("packets_" <> date_str) do
|
||||||
case Date.from_iso8601(
|
case Date.from_iso8601(
|
||||||
String.slice(date_str, 0, 4) <>
|
String.slice(date_str, 0, 4) <>
|
||||||
|
|
|
||||||
|
|
@ -26,19 +26,26 @@ defmodule Aprsme.RegexCache do
|
||||||
Get or compile a regex pattern. Returns {:ok, regex} or {:error, reason}.
|
Get or compile a regex pattern. Returns {:ok, regex} or {:error, reason}.
|
||||||
"""
|
"""
|
||||||
def get_or_compile(pattern_string) do
|
def get_or_compile(pattern_string) do
|
||||||
case :ets.lookup(@table_name, pattern_string) do
|
GenServer.call(__MODULE__, {:get_or_compile, pattern_string})
|
||||||
[{^pattern_string, regex}] ->
|
end
|
||||||
{:ok, regex}
|
|
||||||
|
|
||||||
[] ->
|
@impl true
|
||||||
compile_and_cache(pattern_string)
|
def handle_call({:get_or_compile, pattern_string}, _from, state) do
|
||||||
end
|
result =
|
||||||
|
case :ets.lookup(@table_name, pattern_string) do
|
||||||
|
[{^pattern_string, regex}] ->
|
||||||
|
{:ok, regex}
|
||||||
|
|
||||||
|
[] ->
|
||||||
|
compile_and_cache(pattern_string)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:reply, result, state}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp compile_and_cache(pattern_string) do
|
defp compile_and_cache(pattern_string) do
|
||||||
case Regex.compile(pattern_string) do
|
case Regex.compile(pattern_string) do
|
||||||
{:ok, regex} ->
|
{:ok, regex} ->
|
||||||
# Check cache size and clear if needed
|
|
||||||
if :ets.info(@table_name, :size) >= @max_cache_size do
|
if :ets.info(@table_name, :size) >= @max_cache_size do
|
||||||
clear_oldest_entries()
|
clear_oldest_entries()
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ defmodule Aprsme.Release do
|
||||||
# Notify about deployment after a short delay to ensure PubSub is started
|
# Notify about deployment after a short delay to ensure PubSub is started
|
||||||
# In k8s, this will notify all connected clients about the new deployment
|
# In k8s, this will notify all connected clients about the new deployment
|
||||||
if System.get_env("DEPLOYED_AT") do
|
if System.get_env("DEPLOYED_AT") do
|
||||||
spawn(fn ->
|
Task.start(fn ->
|
||||||
# Wait for application to start
|
# Wait for application to start
|
||||||
Process.sleep(10_000)
|
Process.sleep(10_000)
|
||||||
|
|
||||||
|
|
@ -157,10 +157,8 @@ defmodule Aprsme.Release do
|
||||||
Ecto.Migrator.with_repo(
|
Ecto.Migrator.with_repo(
|
||||||
Aprsme.Repo,
|
Aprsme.Repo,
|
||||||
fn repo ->
|
fn repo ->
|
||||||
# Set session-level timeout for this connection
|
timeout_seconds = div(timeout, 1000)
|
||||||
# credo:disable-for-next-line
|
SQL.query!(repo, "SET statement_timeout = $1", ["#{timeout_seconds}s"])
|
||||||
# sobelow_skip ["SQL.Query"]
|
|
||||||
SQL.query!(repo, "SET statement_timeout = '#{div(timeout, 1000)}s'")
|
|
||||||
Ecto.Migrator.run(repo, :up, all: true)
|
Ecto.Migrator.run(repo, :up, all: true)
|
||||||
end,
|
end,
|
||||||
timeout: timeout
|
timeout: timeout
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@ defmodule Aprsme.SignalHandler do
|
||||||
def handle_info({:signal, :sigterm}, state) do
|
def handle_info({:signal, :sigterm}, state) do
|
||||||
Logger.info("Received SIGTERM signal, initiating graceful shutdown...")
|
Logger.info("Received SIGTERM signal, initiating graceful shutdown...")
|
||||||
|
|
||||||
# Trigger graceful shutdown
|
# Trigger graceful shutdown with spawn_link to ensure cleanup if parent crashes
|
||||||
spawn(fn ->
|
spawn_link(fn ->
|
||||||
Aprsme.ShutdownHandler.shutdown()
|
Aprsme.ShutdownHandler.shutdown()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ defmodule Aprsme.SpatialPubSub do
|
||||||
|
|
||||||
# Grid size in degrees for spatial indexing
|
# Grid size in degrees for spatial indexing
|
||||||
@grid_size 1.0
|
@grid_size 1.0
|
||||||
|
# Maximum number of concurrent clients
|
||||||
|
@max_clients 10_000
|
||||||
|
|
||||||
def start_link(opts \\ []) do
|
def start_link(opts \\ []) do
|
||||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||||
|
|
@ -104,30 +106,30 @@ defmodule Aprsme.SpatialPubSub do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_call({:register_viewport, client_id, bounds}, {pid, _}, state) do
|
def handle_call({:register_viewport, client_id, bounds}, {pid, _}, state) do
|
||||||
# Create a unique topic for this client
|
if map_size(state.clients) >= @max_clients do
|
||||||
topic = "spatial:#{client_id}"
|
{:reply, {:error, :client_limit_exceeded}, state}
|
||||||
|
else
|
||||||
|
topic = "spatial:#{client_id}"
|
||||||
|
|
||||||
state = replace_existing_client(state, client_id)
|
state = replace_existing_client(state, client_id)
|
||||||
|
|
||||||
# Monitor the client process
|
ref = Process.monitor(pid)
|
||||||
ref = Process.monitor(pid)
|
|
||||||
|
|
||||||
# Update client info
|
client_info = %{
|
||||||
client_info = %{
|
bounds: normalize_bounds(bounds),
|
||||||
bounds: normalize_bounds(bounds),
|
topic: topic,
|
||||||
topic: topic,
|
pid: pid,
|
||||||
pid: pid,
|
monitor_ref: ref
|
||||||
monitor_ref: ref
|
}
|
||||||
}
|
|
||||||
|
|
||||||
# Update spatial index
|
new_state =
|
||||||
new_state =
|
state
|
||||||
state
|
|> put_in([:clients, client_id], client_info)
|
||||||
|> put_in([:clients, client_id], client_info)
|
|> update_spatial_index(client_id, client_info.bounds)
|
||||||
|> update_spatial_index(client_id, client_info.bounds)
|
|> update_in([:stats, :clients_count], &(&1 + 1))
|
||||||
|> update_in([:stats, :clients_count], &(&1 + 1))
|
|
||||||
|
|
||||||
{:reply, {:ok, topic}, new_state}
|
{:reply, {:ok, topic}, new_state}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
|
||||||
|
|
@ -80,17 +80,10 @@ defmodule Aprsme.StreamingPacketsPubSub do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_call({:subscribe, pid, bounds}, _from, state) do
|
def handle_call({:subscribe, pid, bounds}, _from, state) do
|
||||||
# Validate bounds
|
|
||||||
if valid_bounds?(bounds) do
|
if valid_bounds?(bounds) do
|
||||||
# Demonitor old ref if this pid was already subscribed (bounds update)
|
|
||||||
state = demonitor_if_exists(state, pid)
|
state = demonitor_if_exists(state, pid)
|
||||||
|
|
||||||
# Monitor the subscriber
|
|
||||||
ref = Process.monitor(pid)
|
ref = Process.monitor(pid)
|
||||||
|
|
||||||
# Store in ETS for fast lookup
|
|
||||||
:ets.insert(@table_name, {pid, bounds})
|
:ets.insert(@table_name, {pid, bounds})
|
||||||
|
|
||||||
{:reply, :ok, put_in(state.monitors[pid], ref)}
|
{:reply, :ok, put_in(state.monitors[pid], ref)}
|
||||||
else
|
else
|
||||||
{:reply, {:error, :invalid_bounds}, state}
|
{:reply, {:error, :invalid_bounds}, state}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ defmodule AprsmeWeb.CoreComponents do
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders a Heroicon SVG inline from priv/heroicons/.
|
Renders a Heroicon SVG inline from priv/heroicons/.
|
||||||
|
SVGs are loaded from trusted vendored heroicons directory and are safe to render as raw HTML.
|
||||||
Usage: <.icon name="arrow-left" outline={true} class="h-5 w-5" />
|
Usage: <.icon name="arrow-left" outline={true} class="h-5 w-5" />
|
||||||
"""
|
"""
|
||||||
attr :name, :string, required: true
|
attr :name, :string, required: true
|
||||||
|
|
@ -37,6 +38,7 @@ defmodule AprsmeWeb.CoreComponents do
|
||||||
case File.read(path) do
|
case File.read(path) do
|
||||||
{:ok, contents} ->
|
{:ok, contents} ->
|
||||||
# Insert class attribute if not present
|
# Insert class attribute if not present
|
||||||
|
# SVG is from trusted vendored heroicons, safe to modify and render
|
||||||
Regex.replace(~r/<svg([^>]*?)>/, contents, fn _, attrs ->
|
Regex.replace(~r/<svg([^>]*?)>/, contents, fn _, attrs ->
|
||||||
add_class_to_svg_tag(attrs, class)
|
add_class_to_svg_tag(attrs, class)
|
||||||
end)
|
end)
|
||||||
|
|
|
||||||
|
|
@ -553,19 +553,6 @@ defmodule AprsmeWeb.InfoLive.Show do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
|
||||||
Renders an APRS symbol style for use in templates.
|
|
||||||
"""
|
|
||||||
def render_symbol_style(packet, size \\ 32) do
|
|
||||||
if packet do
|
|
||||||
{symbol_table_id, symbol_code} = AprsSymbol.extract_from_packet(packet)
|
|
||||||
AprsSymbol.render_style(symbol_table_id, symbol_code, size)
|
|
||||||
else
|
|
||||||
# Return empty style if no packet
|
|
||||||
""
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders an APRS symbol as HTML for overlay symbols that need proper overlay character display.
|
Renders an APRS symbol as HTML for overlay symbols that need proper overlay character display.
|
||||||
"""
|
"""
|
||||||
|
|
@ -573,36 +560,23 @@ defmodule AprsmeWeb.InfoLive.Show do
|
||||||
if packet do
|
if packet do
|
||||||
{symbol_table_id, symbol_code} = AprsSymbol.extract_from_packet(packet)
|
{symbol_table_id, symbol_code} = AprsSymbol.extract_from_packet(packet)
|
||||||
|
|
||||||
# Check if this is an overlay symbol
|
|
||||||
if symbol_table_id && String.match?(symbol_table_id, ~r/^[A-Z0-9]$/) do
|
if symbol_table_id && String.match?(symbol_table_id, ~r/^[A-Z0-9]$/) do
|
||||||
# Use layered sprite backgrounds for overlay symbols
|
|
||||||
sprite_info = AprsSymbol.get_sprite_info(symbol_table_id, symbol_code)
|
sprite_info = AprsSymbol.get_sprite_info(symbol_table_id, symbol_code)
|
||||||
overlay_sprite_info = AprsSymbol.get_overlay_character_sprite_info(symbol_table_id)
|
overlay_sprite_info = AprsSymbol.get_overlay_character_sprite_info(symbol_table_id)
|
||||||
|
|
||||||
raw("""
|
style =
|
||||||
<div style="
|
"position: relative; width: #{size}px; height: #{size}px; background-image: url(#{overlay_sprite_info.sprite_file}), url(#{sprite_info.sprite_file}); background-position: #{overlay_sprite_info.background_position}, #{sprite_info.background_position}; background-size: #{overlay_sprite_info.background_size}, #{sprite_info.background_size}; background-repeat: no-repeat, no-repeat; image-rendering: pixelated; display: inline-block; vertical-align: middle; margin-bottom: -6px;"
|
||||||
position: relative;
|
|
||||||
width: #{size}px;
|
escaped_style = style |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
|
||||||
height: #{size}px;
|
|
||||||
background-image: url(#{overlay_sprite_info.sprite_file}), url(#{sprite_info.sprite_file});
|
raw("<div style=\"#{escaped_style}\"></div>")
|
||||||
background-position: #{overlay_sprite_info.background_position}, #{sprite_info.background_position};
|
|
||||||
background-size: #{overlay_sprite_info.background_size}, #{sprite_info.background_size};
|
|
||||||
background-repeat: no-repeat, no-repeat;
|
|
||||||
image-rendering: pixelated;
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin-bottom: -6px;
|
|
||||||
">
|
|
||||||
</div>
|
|
||||||
""")
|
|
||||||
else
|
else
|
||||||
# Use style rendering for non-overlay symbols
|
style = AprsSymbol.render_style(symbol_table_id, symbol_code, size)
|
||||||
raw("""
|
escaped_style = style |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
|
||||||
<div style="#{AprsSymbol.render_style(symbol_table_id, symbol_code, size)}"></div>
|
|
||||||
""")
|
raw("<div style=\"#{escaped_style}\"></div>")
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
# Return empty if no packet
|
|
||||||
raw("")
|
raw("")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -676,7 +676,9 @@ defmodule AprsmeWeb.MapLive.DataBuilder do
|
||||||
defp historical_dot_html(callsign) do
|
defp historical_dot_html(callsign) do
|
||||||
escaped = callsign |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
|
escaped = callsign |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
|
||||||
|
|
||||||
"<div style=\"width: 8px; height: 8px; background-color: #FF6B6B; border: 2px solid #FFFFFF; border-radius: 50%; opacity: 0.8; box-shadow: 0 0 2px rgba(0,0,0,0.3);\" title=\"Historical position for #{escaped}\"></div>"
|
Phoenix.HTML.raw(
|
||||||
|
"<div style=\"width: 8px; height: 8px; background-color: #FF6B6B; border: 2px solid #FFFFFF; border-radius: 50%; opacity: 0.8; box-shadow: 0 0 2px rgba(0,0,0,0.3);\" title=\"Historical position for #{escaped}\"></div>"
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_historical_packet_data(filtered_historical, has_weather) do
|
defp build_historical_packet_data(filtered_historical, has_weather) do
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,10 @@ defmodule AprsmeWeb.StatusLive.Index do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info(:refresh_status, socket) do
|
def handle_info(:refresh_status, socket) do
|
||||||
# Refresh status asynchronously
|
# Refresh status asynchronously using supervised task
|
||||||
self_pid = self()
|
self_pid = self()
|
||||||
|
|
||||||
Task.start(fn ->
|
Task.Supervisor.start_child(Aprsme.BroadcastTaskSupervisor, fn ->
|
||||||
try do
|
try do
|
||||||
status = get_aprs_status()
|
status = get_aprs_status()
|
||||||
send(self_pid, {:status_updated, status})
|
send(self_pid, {:status_updated, status})
|
||||||
|
|
|
||||||
|
|
@ -79,18 +79,30 @@ defmodule AprsmeWeb.UserConfirmationInstructionsLive do
|
||||||
|
|
||||||
def handle_event("send_instructions", %{"user" => %{"email" => email}}, socket) do
|
def handle_event("send_instructions", %{"user" => %{"email" => email}}, socket) do
|
||||||
if user = Accounts.get_user_by_email(email) do
|
if user = Accounts.get_user_by_email(email) do
|
||||||
Accounts.deliver_user_confirmation_instructions(
|
case Accounts.deliver_user_confirmation_instructions(
|
||||||
user,
|
user,
|
||||||
&url(~p"/users/confirm/#{&1}")
|
&url(~p"/users/confirm/#{&1}")
|
||||||
)
|
) do
|
||||||
|
{:ok, _} ->
|
||||||
|
info =
|
||||||
|
"If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly."
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, info)
|
||||||
|
|> redirect(to: ~p"/")}
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Failed to send email. Please try again later.")}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
info =
|
||||||
|
"If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly."
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, info)
|
||||||
|
|> redirect(to: ~p"/")}
|
||||||
end
|
end
|
||||||
|
|
||||||
info =
|
|
||||||
"If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly."
|
|
||||||
|
|
||||||
{:noreply,
|
|
||||||
socket
|
|
||||||
|> put_flash(:info, info)
|
|
||||||
|> redirect(to: ~p"/")}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -79,18 +79,30 @@ defmodule AprsmeWeb.UserForgotPasswordLive do
|
||||||
|
|
||||||
def handle_event("send_email", %{"user" => %{"email" => email}}, socket) do
|
def handle_event("send_email", %{"user" => %{"email" => email}}, socket) do
|
||||||
if user = Accounts.get_user_by_email(email) do
|
if user = Accounts.get_user_by_email(email) do
|
||||||
Accounts.deliver_user_reset_password_instructions(
|
case Accounts.deliver_user_reset_password_instructions(
|
||||||
user,
|
user,
|
||||||
&url(~p"/users/reset_password/#{&1}")
|
&url(~p"/users/reset_password/#{&1}")
|
||||||
)
|
) do
|
||||||
|
{:ok, _} ->
|
||||||
|
info =
|
||||||
|
"If your email is in our system, you will receive instructions to reset your password shortly."
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, info)
|
||||||
|
|> redirect(to: ~p"/")}
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
{:noreply, put_flash(socket, :error, "Failed to send email. Please try again later.")}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
info =
|
||||||
|
"If your email is in our system, you will receive instructions to reset your password shortly."
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, info)
|
||||||
|
|> redirect(to: ~p"/")}
|
||||||
end
|
end
|
||||||
|
|
||||||
info =
|
|
||||||
"If your email is in our system, you will receive instructions to reset your password shortly."
|
|
||||||
|
|
||||||
{:noreply,
|
|
||||||
socket
|
|
||||||
|> put_flash(:info, info)
|
|
||||||
|> redirect(to: ~p"/")}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -148,14 +148,17 @@ defmodule AprsmeWeb.UserRegistrationLive do
|
||||||
def handle_event("save", %{"user" => user_params}, socket) do
|
def handle_event("save", %{"user" => user_params}, socket) do
|
||||||
case Accounts.register_user(user_params) do
|
case Accounts.register_user(user_params) do
|
||||||
{:ok, user} ->
|
{:ok, user} ->
|
||||||
{:ok, _} =
|
case Accounts.deliver_user_confirmation_instructions(
|
||||||
Accounts.deliver_user_confirmation_instructions(
|
user,
|
||||||
user,
|
&url(~p"/users/confirm/#{&1}")
|
||||||
&url(~p"/users/confirm/#{&1}")
|
) do
|
||||||
)
|
{:ok, _} ->
|
||||||
|
changeset = Accounts.change_user_registration(user)
|
||||||
|
{:noreply, assign(socket, trigger_submit: true, changeset: changeset)}
|
||||||
|
|
||||||
changeset = Accounts.change_user_registration(user)
|
{:error, _reason} ->
|
||||||
{:noreply, assign(socket, trigger_submit: true, changeset: changeset)}
|
{:noreply, put_flash(socket, :error, "Failed to send email. Please try again later.")}
|
||||||
|
end
|
||||||
|
|
||||||
{:error, %Ecto.Changeset{} = changeset} ->
|
{:error, %Ecto.Changeset{} = changeset} ->
|
||||||
{:noreply, assign(socket, :changeset, changeset)}
|
{:noreply, assign(socket, :changeset, changeset)}
|
||||||
|
|
|
||||||
|
|
@ -348,14 +348,18 @@ defmodule AprsmeWeb.UserSettingsLive do
|
||||||
|
|
||||||
case Accounts.apply_user_email(user, password, user_params) do
|
case Accounts.apply_user_email(user, password, user_params) do
|
||||||
{:ok, applied_user} ->
|
{:ok, applied_user} ->
|
||||||
Accounts.deliver_user_update_email_instructions(
|
case Accounts.deliver_user_update_email_instructions(
|
||||||
applied_user,
|
applied_user,
|
||||||
user.email,
|
user.email,
|
||||||
&url(~p"/users/settings/confirm_email/#{&1}")
|
&url(~p"/users/settings/confirm_email/#{&1}")
|
||||||
)
|
) do
|
||||||
|
{:ok, _} ->
|
||||||
|
info = "A link to confirm your email change has been sent to the new address."
|
||||||
|
{:noreply, put_flash(socket, :info, info)}
|
||||||
|
|
||||||
info = "A link to confirm your email change has been sent to the new address."
|
{:error, _reason} ->
|
||||||
{:noreply, put_flash(socket, :info, info)}
|
{:noreply, put_flash(socket, :error, "Failed to send email. Please try again later.")}
|
||||||
|
end
|
||||||
|
|
||||||
{:error, changeset} ->
|
{:error, changeset} ->
|
||||||
{:noreply, assign(socket, :email_changeset, Map.put(changeset, :action, :insert))}
|
{:noreply, assign(socket, :email_changeset, Map.put(changeset, :action, :insert))}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue