towerops/lib/towerops_web/live/mikrotik_backup_live/compare.ex
Graham McIntire 3a408a8dc1 Security hardening + performance fixes across codebase
CRITICAL:
- Membership: remove :role/:org_id/:user_id from mass-assignment cast; use explicit create_changeset/4 and role_update_changeset/2
- GraphQL member resolver: add authorize_invite/3 checking admin/owner role and role hierarchy
- REST invitations controller: add auth check for invite creation

HIGH:
- ApiToken: remove :organization_id/:user_id from cast; use explicit create_changeset/4

MEDIUM:
- Move 8 LiveView Ecto queries into context modules (Admin, Alerts, Coverages, OnCall, Snmp)
- Replace Process.put/Process.get with socket assigns for unresolved_alert_count (user_auth + layouts + 50 templates)
- Add batch get_utilization_for_interfaces/1 to eliminate N+1 capacity queries in device show
- Replace Process.sleep with Process.monitor/assert_receive or Process.send_after in 5 test files

LOW:
- Add handle_params/3 to UserResetPasswordLive, UserRegistrationLive, StatusPageLive
- Remove redundant Repo.preload calls; add preloads to list_site_devices/1
- Fix @impl annotations and credo nesting warnings
2026-06-21 17:40:50 -05:00

125 lines
4.3 KiB
Elixir

defmodule ToweropsWeb.MikrotikBackupLive.Compare do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Devices
alias Towerops.Devices.MikrotikBackups
alias Towerops.Devices.MikrotikBackups.Differ
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"device_id" => device_id, "backup_a" => backup_a_id, "backup_b" => backup_b_id}, _uri, socket) do
organization = socket.assigns.current_scope.organization
# Verify device access
case Devices.get_device(device_id) do
nil ->
{:noreply,
socket
|> put_flash(:error, t("Device not found"))
|> push_navigate(to: ~p"/devices")}
device ->
if device.site.organization_id == organization.id do
load_and_compare_backups(socket, device, device_id, backup_a_id, backup_b_id)
else
{:noreply,
socket
|> put_flash(:error, t("You don't have access to this device"))
|> push_navigate(to: ~p"/devices")}
end
end
end
@impl true
def handle_params(_params, _uri, socket) do
{:noreply,
socket
|> put_flash(:error, t("Missing required parameters"))
|> push_navigate(to: ~p"/devices")}
end
defp load_and_compare_backups(socket, device, device_id, backup_a_id, backup_b_id) do
# Load backups
backup_a = MikrotikBackups.get_backup!(backup_a_id)
backup_b = MikrotikBackups.get_backup!(backup_b_id)
# Verify backups belong to this device
if backup_a.device_id != device_id || backup_b.device_id != device_id do
{:noreply,
socket
|> put_flash(:error, t("Backups do not belong to this device"))
|> push_navigate(to: ~p"/devices/#{device_id}?tab=backups")}
else
generate_and_display_diff(socket, device, backup_a, backup_b, device_id)
end
end
defp generate_and_display_diff(socket, device, backup_a, backup_b, device_id) do
# Decompress configs
config_a = MikrotikBackups.decompress_config(backup_a.config_compressed)
config_b = MikrotikBackups.decompress_config(backup_b.config_compressed)
# Mask sensitive data
config_a_masked = Differ.mask_sensitive_data(config_a)
config_b_masked = Differ.mask_sensitive_data(config_b)
# Generate diff
case Differ.generate_unified_diff(config_a_masked, config_b_masked) do
{:ok, diff_output} ->
stats = Differ.calculate_diff_stats(diff_output)
{:noreply,
socket
|> assign(:page_title, "Compare Backups - #{device.name}")
|> assign(:device, device)
|> assign(:backup_a, backup_a)
|> assign(:backup_b, backup_b)
|> assign(:diff_output, diff_output)
|> assign(:stats, stats)}
{:error, reason} ->
{:noreply,
socket
|> put_flash(:error, t("Failed to generate diff: %{reason}", reason: reason))
|> push_navigate(to: ~p"/devices/#{device_id}?tab=backups")}
end
end
@impl true
def handle_event("download_config", %{"backup" => "a"}, socket) do
backup = socket.assigns.backup_a
config_text = MikrotikBackups.decompress_config(backup.config_compressed)
device = socket.assigns.device
filename = "#{device.name}_backup_#{Calendar.strftime(backup.backed_up_at, "%Y%m%d_%H%M%S")}.rsc"
{:noreply, push_event(socket, "download", %{content: config_text, filename: filename, mime_type: "text/plain"})}
end
@impl true
def handle_event("download_config", %{"backup" => "b"}, socket) do
backup = socket.assigns.backup_b
config_text = MikrotikBackups.decompress_config(backup.config_compressed)
device = socket.assigns.device
filename = "#{device.name}_backup_#{Calendar.strftime(backup.backed_up_at, "%Y%m%d_%H%M%S")}.rsc"
{:noreply, push_event(socket, "download", %{content: config_text, filename: filename, mime_type: "text/plain"})}
end
@doc false
def format_bytes(bytes) when is_integer(bytes) and bytes >= 1_073_741_824,
do: "#{Float.round(bytes / 1_073_741_824, 1)} GB"
def format_bytes(bytes) when is_integer(bytes) and bytes >= 1_048_576, do: "#{Float.round(bytes / 1_048_576, 1)} MB"
def format_bytes(bytes) when is_integer(bytes) and bytes >= 1_024, do: "#{Float.round(bytes / 1_024, 1)} KB"
def format_bytes(bytes) when is_integer(bytes) and bytes >= 0, do: "#{bytes} B"
def format_bytes(_), do: "Invalid byte count"
end