towerops/lib/towerops_web/controllers/api/v1/activity_controller.ex
Graham McIntire ea91dae0e6 fix: 6 medium-severity bugs (M2, M8, M9, M10, M11, M16)
- M2: get_user_by_email/1 uses lower(email) = lower(?) so User@Example.com
  and user@example.com resolve to the same account; closes a lookalike-
  registration / password-reset confusion risk.
- M8: webhook auth plug no longer echoes "Webhook authentication not
  configured" — the misconfiguration is logged server-side and the caller
  gets a generic Internal server error so endpoints can't be probed.
- M9: coverages controller logs the underlying KMZ build error and
  returns a generic "Failed to build KMZ" string — filesystem paths and
  zip internals no longer leak.
- M10: account-data controller no longer crashes with MatchError when an
  org has zero or multiple memberships; defaults to "member" and treats
  any owner membership as owner.
- M11: ActivityController switches to a hard whitelist
  (Atom.to_string/1 comparison) instead of String.to_existing_atom/1;
  unknown filter types are dropped silently and the atom table can't be
  grown from API input.
- M16: title-tracking MutationObserver is stored on `this` so destroyed()
  actually disconnects it — fixes a memory leak per LV navigation.
2026-05-12 11:38:13 -05:00

57 lines
1.6 KiB
Elixir

defmodule ToweropsWeb.Api.V1.ActivityController do
@moduledoc "API controller for the organization activity feed."
use ToweropsWeb, :controller
alias Towerops.ActivityFeed
def index(conn, params) do
organization_id = conn.assigns.current_organization_id
opts =
[]
|> maybe_add(:limit, parse_int(params["limit"], 50))
|> maybe_add_types(params["types"])
items = ActivityFeed.list_org_activity(organization_id, opts)
json(conn, %{data: items})
end
defp maybe_add(opts, key, value), do: Keyword.put(opts, key, value)
defp maybe_add_types(opts, nil), do: opts
# Allowlist of activity types the API accepts. Anything else is dropped
# silently. Using a hard list avoids `String.to_existing_atom/1` which
# would convert any string that happens to match an internal atom
# somewhere in the BEAM — that produces unpredictable filter behavior
# and grows the atom table from user input.
@allowed_types ~w(config_change alert_fired alert_resolved device_event sync device_added)a
defp maybe_add_types(opts, types) when is_binary(types) do
type_atoms =
types
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.flat_map(&allowed_type/1)
Keyword.put(opts, :types, type_atoms)
end
defp maybe_add_types(opts, _), do: opts
defp allowed_type(str) do
Enum.filter(@allowed_types, fn t -> Atom.to_string(t) == str end)
end
defp parse_int(nil, default), do: default
defp parse_int(val, default) when is_binary(val) do
case Integer.parse(val) do
{n, _} -> n
:error -> default
end
end
defp parse_int(val, _default) when is_integer(val), do: val
end