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:
Graham McIntire 2026-03-23 16:59:00 -05:00
parent 4477fdf615
commit 288b9fbbb2
No known key found for this signature in database
22 changed files with 359 additions and 208 deletions

View file

@ -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})
[ [

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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) <>

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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}

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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})

View file

@ -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

View file

@ -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

View file

@ -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)}

View file

@ -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))}