diff --git a/.tool-versions b/.tool-versions index e2e1f7a..5e93d4e 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -erlang 29.0.1 +erlang 29.0.3 elixir 1.20.2-otp-29 diff --git a/config/config.exs b/config/config.exs index 0d479fb..d8d5a83 100644 --- a/config/config.exs +++ b/config/config.exs @@ -31,7 +31,7 @@ config :aprsme, AprsmeWeb.Endpoint, layout: false ], pubsub_server: Aprsme.PubSub, - live_view: [signing_salt: "ees098qG"] + live_view: [signing_salt: "dev-signing-salt-local-only"] # Configure Gettext with supported locales from AprsmeWeb.Gettext module config :aprsme, AprsmeWeb.Gettext, diff --git a/config/runtime.exs b/config/runtime.exs index 01f3c71..49b95ff 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -43,6 +43,20 @@ if config_env() == :prod do You can generate one by calling: mix phx.gen.secret """ + live_view_signing_salt = + System.get_env("LIVE_VIEW_SIGNING_SALT") || + raise """ + environment variable LIVE_VIEW_SIGNING_SALT is missing. + You can generate one by calling: mix phx.gen.secret 32 + """ + + encryption_salt = + System.get_env("ENCRYPTION_SALT") || + raise """ + environment variable ENCRYPTION_SALT is missing. + You can generate one by calling: mix phx.gen.secret 32 + """ + host = System.get_env("PHX_HOST") || "example.com" port = String.to_integer(System.get_env("PORT") || "4000") @@ -99,6 +113,8 @@ if config_env() == :prod do port: port ], secret_key_base: secret_key_base, + live_view: [signing_salt: live_view_signing_salt], + session_options: [encryption_salt: encryption_salt, secure: true], server: true, check_origin: [ "https://#{host}", diff --git a/lib/aprsme_web/endpoint.ex b/lib/aprsme_web/endpoint.ex index 45dc65a..670055c 100644 --- a/lib/aprsme_web/endpoint.ex +++ b/lib/aprsme_web/endpoint.ex @@ -5,13 +5,25 @@ defmodule AprsmeWeb.Endpoint do # The session will be stored in the cookie and signed, # this means its contents can be read but not tampered with. # Set :encryption_salt if you would also like to encrypt it. - @session_options [ + @default_session_options [ store: :cookie, key: "_aprs_key", signing_salt: "0toQ/Ejk", + encryption_salt: "local-dev-only-encryption-salt", + secure: false, same_site: "Lax" ] + # Computed at compile-time by merging defaults with any endpoint config + @session_options Keyword.merge( + @default_session_options, + :aprsme + |> Application.compile_env(AprsmeWeb.Endpoint, []) + |> Keyword.get(:session_options, []) + ) + + def session_options, do: @session_options + socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options], timeout: 60_000], longpoll: [connect_info: [session: @session_options]] diff --git a/lib/aprsme_web/live/info_live/show.ex b/lib/aprsme_web/live/info_live/show.ex index 1ba2fd2..a09c9dc 100644 --- a/lib/aprsme_web/live/info_live/show.ex +++ b/lib/aprsme_web/live/info_live/show.ex @@ -81,9 +81,6 @@ defmodule AprsmeWeb.InfoLive.Show do has_weather_packets = PacketUtils.has_weather_packets?(normalized_callsign) other_ssids = Packets.get_other_ssids(normalized_callsign) - heard_by_stations = get_heard_by_stations(normalized_callsign, locale) - stations_heard_by = get_stations_heard_by(normalized_callsign, locale) - socket = socket |> assign(:callsign, normalized_callsign) @@ -92,8 +89,8 @@ defmodule AprsmeWeb.InfoLive.Show do |> assign(:page_title, "APRS station #{normalized_callsign}") |> assign(:has_weather_packets, has_weather_packets) |> assign(:other_ssids, other_ssids) - |> assign(:heard_by_stations, heard_by_stations) - |> assign(:stations_heard_by, stations_heard_by) + + send(self(), :load_aggregations) {:ok, socket} end @@ -105,6 +102,15 @@ defmodule AprsmeWeb.InfoLive.Show do process_packet_update(packet, socket) end + @impl true + def handle_info(:load_aggregations, socket) do + locale = Map.get(socket.assigns, :locale, "en") + heard_by_stations = get_heard_by_stations(socket.assigns.callsign, locale) + stations_heard_by = get_stations_heard_by(socket.assigns.callsign, locale) + + {:noreply, assign(socket, heard_by_stations: heard_by_stations, stations_heard_by: stations_heard_by)} + end + def handle_info(_message, socket), do: {:noreply, socket} defp process_packet_update(incoming_packet, socket) do @@ -326,7 +332,27 @@ defmodule AprsmeWeb.InfoLive.Show do # Query to find stations that heard this callsign directly # In APRS, the first station with an asterisk (*) in the path is the one that heard the packet directly query = """ - WITH parsed_paths AS ( + WITH digipeater_location AS ( + SELECT DISTINCT ON (sender) + sender as digipeater, + location::geography as dig_loc + FROM packets + WHERE sender IN ( + SELECT DISTINCT + substring(path from '([A-Z0-9]+-?[0-9]*)\\*') + FROM packets + WHERE sender = $1 + AND received_at >= $2 + AND path IS NOT NULL + AND path != '' + AND path !~ '^TCPIP' + AND path !~ ',TCPIP' + AND path ~ '[A-Z0-9]+-?[0-9]*\\*' + ) + AND location IS NOT NULL + ORDER BY sender, received_at DESC + ), + parsed_paths AS ( SELECT id, sender, @@ -349,33 +375,34 @@ defmodule AprsmeWeb.InfoLive.Show do AND path !~ '^TCPIP' AND path !~ ',TCPIP' ), + longest_paths AS ( + SELECT DISTINCT ON (pp.first_digipeater) + pp.first_digipeater, + pp.id as longest_path_packet_id + FROM parsed_paths pp + WHERE pp.first_digipeater IS NOT NULL + AND pp.lat IS NOT NULL + AND pp.lon IS NOT NULL + ORDER BY pp.first_digipeater, + ST_Distance( + pp.location::geography, + COALESCE( + (SELECT dl.dig_loc FROM digipeater_location dl WHERE dl.digipeater = pp.first_digipeater), + pp.location::geography + ) + ) DESC NULLS LAST + ), digipeater_stats AS ( SELECT - first_digipeater as digipeater, - MIN(received_at) as first_heard, - MAX(received_at) as last_heard, + pp.first_digipeater as digipeater, + MIN(pp.received_at) as first_heard, + MAX(pp.received_at) as last_heard, COUNT(*) as packet_count, - -- Find the packet with maximum distance for this digipeater - (SELECT pp2.id - FROM parsed_paths pp2 - WHERE pp2.first_digipeater = pp.first_digipeater - AND pp2.lat IS NOT NULL - AND pp2.lon IS NOT NULL - ORDER BY - ST_Distance( - pp2.location::geography, - (SELECT location::geography - FROM packets - WHERE sender = pp2.first_digipeater - AND location IS NOT NULL - ORDER BY received_at DESC - LIMIT 1) - ) DESC NULLS LAST - LIMIT 1 - ) as longest_path_packet_id + MAX(lp.longest_path_packet_id) as longest_path_packet_id FROM parsed_paths pp - WHERE first_digipeater IS NOT NULL - GROUP BY first_digipeater + LEFT JOIN longest_paths lp ON lp.first_digipeater = pp.first_digipeater + WHERE pp.first_digipeater IS NOT NULL + GROUP BY pp.first_digipeater ) SELECT ds.digipeater, @@ -384,20 +411,13 @@ defmodule AprsmeWeb.InfoLive.Show do ds.packet_count, p.received_at as longest_path_time, CASE - WHEN p.location IS NOT NULL AND dig_loc.location IS NOT NULL THEN - ST_Distance(p.location::geography, dig_loc.location::geography) / 1000.0 + WHEN p.location IS NOT NULL AND dl.dig_loc IS NOT NULL THEN + ST_Distance(p.location::geography, dl.dig_loc) / 1000.0 ELSE NULL END as longest_distance_km FROM digipeater_stats ds LEFT JOIN packets p ON p.id = ds.longest_path_packet_id - LEFT JOIN LATERAL ( - SELECT location - FROM packets - WHERE sender = ds.digipeater - AND location IS NOT NULL - ORDER BY received_at DESC - LIMIT 1 - ) dig_loc ON true + LEFT JOIN digipeater_location dl ON dl.digipeater = ds.digipeater ORDER BY ds.last_heard DESC LIMIT 50 """ diff --git a/lib/aprsme_web/router.ex b/lib/aprsme_web/router.ex index 8f867f0..d37c09c 100644 --- a/lib/aprsme_web/router.ex +++ b/lib/aprsme_web/router.ex @@ -22,7 +22,7 @@ defmodule AprsmeWeb.Router do plug :put_secure_browser_headers, %{ "content-security-policy" => - "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.sentry-cdn.com https://unpkg.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://a.w5isp.com; style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: https: http: blob:; font-src 'self' data:; connect-src 'self' wss: https://*.ingest.sentry.io https://*.sentry.io https://nominatim.openstreetmap.org https://tile.openstreetmap.org https://*.tile.openstreetmap.org https://*.tile.openstreetmap.de https://*.basemaps.cartocdn.com https://a.w5isp.com; media-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; frame-src 'self'; manifest-src 'self'; worker-src 'self' blob:" + "default-src 'self'; script-src 'self' 'unsafe-inline' https://js.sentry-cdn.com https://unpkg.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://a.w5isp.com; style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: https: http: blob:; font-src 'self' data:; connect-src 'self' wss: https://*.ingest.sentry.io https://*.sentry.io https://nominatim.openstreetmap.org https://tile.openstreetmap.org https://*.tile.openstreetmap.org https://*.tile.openstreetmap.de https://*.basemaps.cartocdn.com https://a.w5isp.com; media-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; frame-src 'self'; manifest-src 'self'; worker-src 'self' blob:" } plug :fetch_current_user @@ -74,7 +74,10 @@ defmodule AprsmeWeb.Router do # Prometheus metrics endpoint — internal-only, scraped by the cluster's # prometheus via the kube-apiserver pod proxy. Not exposed via Ingress. - forward "/metrics", PromEx.Plug, prom_ex_module: Aprsme.PromEx + scope "/metrics" do + pipe_through [:public_api] + forward "/", PromEx.Plug, prom_ex_module: Aprsme.PromEx + end # Agent/crawler discovery endpoints scope "/", AprsmeWeb do