Compare commits

...

13 Commits

Author SHA1 Message Date
CI
078e5fc19e chore: release version v1.90.4 2025-12-12 17:07:55 +00:00
Dmitry Popov
3877e121c3 fix(core): fixed map scopes & signatures clean up behaviour 2025-12-12 18:07:18 +01:00
CI
dcb2a0cdb2 chore: [skip ci] 2025-12-11 00:17:06 +00:00
CI
f5294eee84 chore: release version v1.90.3 2025-12-11 00:17:06 +00:00
Dmitry Popov
a5c87b6fa4 Merge branch 'main' of github.com:wanderer-industries/wanderer 2025-12-11 01:16:27 +01:00
Dmitry Popov
eae275f515 fix(core): added pagination for long ACL lists 2025-12-11 01:16:24 +01:00
CI
68ae6706dd chore: [skip ci] 2025-12-10 23:56:28 +00:00
CI
a34b30af15 chore: release version v1.90.2 2025-12-10 23:56:28 +00:00
Dmitry Popov
38b49266ed fix(core): added system position updates to SSE
Some checks failed
Build Test / 🚀 Deploy to test env (fly.io) (push) Has been cancelled
Build Test / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
Build Develop / 🛠 Build (1.17, 18.x, 27) (push) Has been cancelled
🧪 Test Suite / Test Suite (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/amd64) (push) Has been cancelled
Build Develop / 🛠 Build Docker Images (linux/arm64) (push) Has been cancelled
Build Develop / merge (push) Has been cancelled
Build Develop / 🏷 Notify about develop release (push) Has been cancelled
2025-12-11 00:55:52 +01:00
CI
049884bb4c chore: [skip ci] 2025-12-08 21:56:20 +00:00
CI
3c75b2b59f chore: release version v1.90.1 2025-12-08 21:56:20 +00:00
Dmitry Popov
4ad5d191a3 fix(core): fixed connections and signatures remove issues, added comprehensive audit log for auto removed connections and signatures 2025-12-08 22:55:39 +01:00
CI
2499c24cc1 chore: [skip ci] 2025-12-06 10:58:14 +00:00
16 changed files with 551 additions and 96 deletions

View File

@@ -2,6 +2,42 @@
<!-- changelog -->
## [v1.90.4](https://github.com/wanderer-industries/wanderer/compare/v1.90.3...v1.90.4) (2025-12-12)
### Bug Fixes:
* core: fixed map scopes & signatures clean up behaviour
## [v1.90.3](https://github.com/wanderer-industries/wanderer/compare/v1.90.2...v1.90.3) (2025-12-11)
### Bug Fixes:
* core: added pagination for long ACL lists
## [v1.90.2](https://github.com/wanderer-industries/wanderer/compare/v1.90.1...v1.90.2) (2025-12-10)
### Bug Fixes:
* core: added system position updates to SSE
## [v1.90.1](https://github.com/wanderer-industries/wanderer/compare/v1.90.0...v1.90.1) (2025-12-08)
### Bug Fixes:
* core: fixed connections and signatures remove issues, added comprehensive audit log for auto removed connections and signatures
## [v1.90.0](https://github.com/wanderer-industries/wanderer/compare/v1.89.6...v1.90.0) (2025-12-06)

View File

@@ -98,8 +98,8 @@ defmodule WandererApp.ExternalEvents.JsonApiFormatter do
"id" => payload["system_id"] || payload[:system_id],
"attributes" => %{
"locked" => payload["locked"] || payload[:locked],
"x" => payload["x"] || payload[:x],
"y" => payload["y"] || payload[:y],
"position_x" => payload["position_x"] || payload[:position_x],
"position_y" => payload["position_y"] || payload[:position_y],
"updated_at" => event.timestamp
},
"relationships" => %{

View File

@@ -829,7 +829,8 @@ defmodule WandererApp.Map.Server.CharactersImpl do
)
|> case do
true ->
# Add new location system
# Connection is valid (at least one system matches scopes)
# Add BOTH systems including border systems - filtering already done by is_connection_valid
case SystemsImpl.maybe_add_system(map_id, location, old_location, map_opts) do
:ok ->
:ok

View File

@@ -349,6 +349,27 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
solar_system_source: solar_system_source_id,
solar_system_target: solar_system_target_id
} ->
# Emit telemetry for connection auto-deletion
:telemetry.execute(
[:wanderer_app, :map, :connection_cleanup, :delete],
%{system_time: System.system_time()},
%{
map_id: map_id,
solar_system_source_id: solar_system_source_id,
solar_system_target_id: solar_system_target_id,
reason: :auto_cleanup
}
)
# Log auto-deletion for audit trail (no user/character context for auto-cleanup)
WandererApp.User.ActivityTracker.track_map_event(:map_connection_removed, %{
character_id: nil,
user_id: nil,
map_id: map_id,
solar_system_source_id: solar_system_source_id,
solar_system_target_id: solar_system_target_id
})
delete_connection(map_id, %{
solar_system_source_id: solar_system_source_id,
solar_system_target_id: solar_system_target_id
@@ -723,15 +744,33 @@ defmodule WandererApp.Map.Server.ConnectionsImpl do
when is_list(scopes) and from_solar_system_id != to_solar_system_id do
with {:ok, from_system_static_info} <- get_system_static_info(from_solar_system_id),
{:ok, to_system_static_info} <- get_system_static_info(to_solar_system_id) do
# Connection is valid if:
# 1. Neither system is prohibited
# 2. At least one system matches one of the selected scopes
not is_prohibited_system_class?(from_system_static_info.system_class) and
not is_prohibited_system_class?(to_system_static_info.system_class) and
not (@prohibited_systems |> Enum.member?(from_solar_system_id)) and
not (@prohibited_systems |> Enum.member?(to_solar_system_id)) and
(system_matches_any_scope?(from_system_static_info.system_class, scopes) or
system_matches_any_scope?(to_system_static_info.system_class, scopes))
# First check: neither system is prohibited
not_prohibited =
not is_prohibited_system_class?(from_system_static_info.system_class) and
not is_prohibited_system_class?(to_system_static_info.system_class) and
not (@prohibited_systems |> Enum.member?(from_solar_system_id)) and
not (@prohibited_systems |> Enum.member?(to_solar_system_id))
if not_prohibited do
from_is_wormhole = from_system_static_info.system_class in @wh_space
to_is_wormhole = to_system_static_info.system_class in @wh_space
wormholes_enabled = :wormholes in scopes
# Wormhole border behavior: if wormholes scope is enabled AND at least one
# system is a wormhole, allow the connection (adds border k-space systems)
# Otherwise: BOTH systems must match the configured scopes
if wormholes_enabled and (from_is_wormhole or to_is_wormhole) do
# At least one system matches (wormhole matches :wormholes, or other matches its scope)
system_matches_any_scope?(from_system_static_info.system_class, scopes) or
system_matches_any_scope?(to_system_static_info.system_class, scopes)
else
# Non-wormhole movement: both systems must match scopes
system_matches_any_scope?(from_system_static_info.system_class, scopes) and
system_matches_any_scope?(to_system_static_info.system_class, scopes)
end
else
false
end
else
_ -> false
end

View File

@@ -383,6 +383,16 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|> Enum.each(fn connection ->
try do
Logger.debug(fn -> "Removing connection from map: #{inspect(connection)}" end)
# Audit logging for cascade deletion (no user/character context)
WandererApp.User.ActivityTracker.track_map_event(:map_connection_removed, %{
character_id: nil,
user_id: nil,
map_id: map_id,
solar_system_source_id: connection.solar_system_source,
solar_system_target_id: connection.solar_system_target
})
:ok = WandererApp.MapConnectionRepo.destroy(map_id, connection)
:ok = WandererApp.Map.remove_connection(map_id, connection)
Impl.broadcast!(map_id, :remove_connections, [connection])
@@ -393,55 +403,76 @@ defmodule WandererApp.Map.Server.SystemsImpl do
end)
end
# When destination systems are deleted, unlink signatures instead of destroying them.
# This preserves the user's scan data while removing the stale link.
defp cleanup_linked_signatures(map_id, removed_solar_system_ids) do
removed_solar_system_ids
|> Enum.map(fn solar_system_id ->
WandererApp.Api.MapSystemSignature.by_linked_system_id!(solar_system_id)
end)
|> List.flatten()
|> Enum.uniq_by(& &1.system_id)
|> Enum.each(fn s ->
try do
{:ok, %{eve_id: eve_id, system: system}} = s |> Ash.load([:system])
# Group signatures by their source system for efficient broadcasting
signatures_by_system =
removed_solar_system_ids
|> Enum.flat_map(fn solar_system_id ->
WandererApp.Api.MapSystemSignature.by_linked_system_id!(solar_system_id)
end)
|> Enum.uniq_by(& &1.id)
|> Enum.group_by(fn sig -> sig.system_id end)
# Use Ash.destroy (not destroy!) to handle already-deleted signatures gracefully
case Ash.destroy(s) do
:ok ->
# Handle case where parent system was already deleted
case system do
nil ->
Logger.debug(fn ->
"[cleanup_linked_signatures] signature #{eve_id} destroyed (parent system already deleted)"
end)
signatures_by_system
|> Enum.each(fn {_system_id, signatures} ->
signatures
|> Enum.each(fn sig ->
try do
{:ok, %{eve_id: eve_id, system: system}} = sig |> Ash.load([:system])
%{solar_system_id: solar_system_id} ->
Logger.debug(fn ->
"[cleanup_linked_signatures] for system #{solar_system_id}: #{inspect(eve_id)}"
end)
# Clear the linked_system_id instead of destroying the signature
case WandererApp.Api.MapSystemSignature.update_linked_system(sig, %{
linked_system_id: nil
}) do
{:ok, _updated_sig} ->
case system do
nil ->
Logger.debug(fn ->
"[cleanup_linked_signatures] signature #{eve_id} unlinked (parent system already deleted)"
end)
Impl.broadcast!(map_id, :signatures_updated, solar_system_id)
end
%{solar_system_id: solar_system_id} ->
Logger.debug(fn ->
"[cleanup_linked_signatures] unlinked signature #{eve_id} in system #{solar_system_id}"
end)
{:error, %Ash.Error.Invalid{errors: errors}} ->
# Check if this is a StaleRecord error (signature already deleted)
if Enum.any?(errors, &match?(%Ash.Error.Changes.StaleRecord{}, &1)) do
Logger.debug(fn ->
"[cleanup_linked_signatures] signature #{eve_id} already deleted (StaleRecord)"
end)
else
# Audit logging for cascade unlink (no user/character context)
WandererApp.User.ActivityTracker.track_map_event(:signatures_unlinked, %{
character_id: nil,
user_id: nil,
map_id: map_id,
solar_system_id: solar_system_id,
signatures: [eve_id]
})
end
{:error, error} ->
Logger.error(
"[cleanup_linked_signatures] Failed to destroy signature #{eve_id}: #{inspect(errors)}"
"[cleanup_linked_signatures] Failed to unlink signature #{sig.eve_id}: #{inspect(error)}"
)
end
{:error, error} ->
Logger.error(
"[cleanup_linked_signatures] Failed to destroy signature: #{inspect(error)}"
)
end
rescue
e ->
Logger.error("Failed to cleanup linked signature: #{inspect(e)}")
end
rescue
e ->
Logger.error("Failed to cleanup linked signature: #{inspect(e)}")
end)
# Broadcast once per source system after all its signatures are processed
case List.first(signatures) do
%{system: %{solar_system_id: solar_system_id}} ->
Impl.broadcast!(map_id, :signatures_updated, solar_system_id)
_ ->
# Try to get the system info if not preloaded
case List.first(signatures) |> Ash.load([:system]) do
{:ok, %{system: %{solar_system_id: solar_system_id}}} ->
Impl.broadcast!(map_id, :signatures_updated, solar_system_id)
_ ->
:ok
end
end
end)
end
@@ -466,8 +497,32 @@ defmodule WandererApp.Map.Server.SystemsImpl do
end)
end
def maybe_add_system(map_id, location, old_location, map_opts)
def maybe_add_system(map_id, location, old_location, map_opts, scopes \\ nil)
def maybe_add_system(map_id, location, old_location, map_opts, scopes)
when not is_nil(location) do
alias WandererApp.Map.Server.ConnectionsImpl
# Check if the system matches the map's configured scopes before adding
should_add =
case scopes do
nil -> true
[] -> true
scopes when is_list(scopes) ->
ConnectionsImpl.can_add_location(scopes, location.solar_system_id)
end
if should_add do
do_add_system_from_location(map_id, location, old_location, map_opts)
else
# System filtered out by scope settings - this is expected behavior
:ok
end
end
def maybe_add_system(_map_id, _location, _old_location, _map_opts, _scopes), do: :ok
defp do_add_system_from_location(map_id, location, old_location, map_opts) do
:telemetry.execute(
[:wanderer_app, :map, :system_addition, :start],
%{system_time: System.system_time()},
@@ -546,12 +601,14 @@ defmodule WandererApp.Map.Server.SystemsImpl do
|> case do
{:ok, solar_system_info} ->
# Use upsert instead of create - handles race conditions gracefully
# visible: true ensures previously-deleted systems become visible again
WandererApp.MapSystemRepo.upsert(%{
map_id: map_id,
solar_system_id: location.solar_system_id,
name: solar_system_info.solar_system_name,
position_x: position.x,
position_y: position.y
position_y: position.y,
visible: true
})
|> case do
{:ok, system} ->
@@ -673,8 +730,6 @@ defmodule WandererApp.Map.Server.SystemsImpl do
end
end
def maybe_add_system(_map_id, _location, _old_location, _map_opts), do: :ok
defp do_add_system(
map_id,
%{
@@ -1008,12 +1063,16 @@ defmodule WandererApp.Map.Server.SystemsImpl do
# ADDITIVE: Also broadcast to external event system (webhooks/WebSocket)
# This may fail if the relay is not available (e.g., in tests), which is fine
WandererApp.ExternalEvents.broadcast(map_id, :system_metadata_changed, %{
system_id: updated_system.id,
solar_system_id: updated_system.solar_system_id,
name: updated_system.name,
temporary_name: updated_system.temporary_name,
labels: updated_system.labels,
description: updated_system.description,
status: updated_system.status
status: updated_system.status,
locked: updated_system.locked,
position_x: updated_system.position_x,
position_y: updated_system.position_y
})
:ok

View File

@@ -455,7 +455,9 @@ defmodule WandererAppWeb.AccessListMemberAPIController do
end)
{:error, error} ->
Logger.warning("Failed to invalidate map_characters cache for ACL #{acl_id}: #{inspect(error)}")
Logger.warning(
"Failed to invalidate map_characters cache for ACL #{acl_id}: #{inspect(error)}"
)
end
end

View File

@@ -42,12 +42,18 @@ defmodule WandererAppWeb.AuthController do
WandererApp.Character.update_character(character.id, character_update)
# Update corporation/alliance data from ESI to ensure access control is current
update_character_affiliation(character)
{:ok, character}
{:error, _error} ->
{:ok, character} = WandererApp.Api.Character.create(character_data)
:telemetry.execute([:wanderer_app, :user, :character, :registered], %{count: 1})
# Fetch initial corporation/alliance data for new characters
update_character_affiliation(character)
{:ok, character}
end
@@ -113,4 +119,102 @@ defmodule WandererAppWeb.AuthController do
end
def maybe_update_character_user_id(_character, _user_id), do: :ok
# Updates character's corporation and alliance data from ESI.
# This ensures ACL-based access control uses current corporation membership,
# even for characters not actively being tracked on any map.
defp update_character_affiliation(%{id: character_id, eve_id: eve_id} = character) do
# Run async to not block the SSO callback
Task.start(fn ->
character_eve_id = eve_id |> String.to_integer()
case WandererApp.Esi.post_characters_affiliation([character_eve_id]) do
{:ok, [affiliation_info]} when is_map(affiliation_info) ->
new_corporation_id = Map.get(affiliation_info, "corporation_id")
new_alliance_id = Map.get(affiliation_info, "alliance_id")
# Check if corporation changed
corporation_changed = character.corporation_id != new_corporation_id
alliance_changed = character.alliance_id != new_alliance_id
if corporation_changed or alliance_changed do
update_affiliation_data(character_id, character, new_corporation_id, new_alliance_id)
end
{:error, error} ->
Logger.warning(
"[AuthController] Failed to fetch affiliation for character #{character_id}: #{inspect(error)}"
)
_ ->
:ok
end
end)
end
defp update_character_affiliation(_character), do: :ok
defp update_affiliation_data(character_id, character, corporation_id, alliance_id) do
# Fetch corporation info
corporation_update =
case WandererApp.Esi.get_corporation_info(corporation_id) do
{:ok, %{"name" => corp_name, "ticker" => corp_ticker}} ->
%{
corporation_id: corporation_id,
corporation_name: corp_name,
corporation_ticker: corp_ticker
}
_ ->
%{corporation_id: corporation_id}
end
# Fetch alliance info if present
alliance_update =
case alliance_id do
nil ->
%{alliance_id: nil, alliance_name: nil, alliance_ticker: nil}
_ ->
case WandererApp.Esi.get_alliance_info(alliance_id) do
{:ok, %{"name" => alliance_name, "ticker" => alliance_ticker}} ->
%{
alliance_id: alliance_id,
alliance_name: alliance_name,
alliance_ticker: alliance_ticker
}
_ ->
%{alliance_id: alliance_id}
end
end
full_update = Map.merge(corporation_update, alliance_update)
# Update database
case character.corporation_id != corporation_id do
true ->
{:ok, _} = WandererApp.Api.Character.update_corporation(character, corporation_update)
false ->
:ok
end
case character.alliance_id != alliance_id do
true ->
{:ok, _} = WandererApp.Api.Character.update_alliance(character, alliance_update)
false ->
:ok
end
# Update cache
WandererApp.Character.update_character(character_id, full_update)
Logger.info(
"[AuthController] Updated affiliation for character #{character_id}: " <>
"corp #{character.corporation_id} -> #{corporation_id}, " <>
"alliance #{character.alliance_id} -> #{alliance_id}"
)
end
end

View File

@@ -4,6 +4,7 @@ defmodule WandererAppWeb.MapAuditAPIController do
require Logger
alias WandererAppWeb.UserActivityItem
alias WandererAppWeb.Helpers.APIUtils
# -----------------------------------------------------------------
@@ -153,10 +154,10 @@ defmodule WandererAppWeb.MapAuditAPIController do
result
|> Map.put(:character, WandererAppWeb.MapEventHandler.map_ui_character_stat(character))
|> Map.put(:event_name, WandererAppWeb.UserActivity.get_event_name(event_type))
|> Map.put(:event_name, WandererAppWeb.UserActivityItem.get_event_name(event_type))
|> Map.put(
:event_data,
WandererAppWeb.UserActivity.get_event_data(
WandererAppWeb.UserActivityItem.get_event_data(
event_type,
Jason.decode!(event_data) |> Map.drop(["character_id"])
)

View File

@@ -5,6 +5,8 @@ defmodule WandererAppWeb.AccessListsLive do
require Ash.Query
require Logger
@members_per_page 50
@impl true
def mount(_params, %{"user_id" => user_id} = _session, socket) when not is_nil(user_id) do
{:ok, characters} = WandererApp.Api.Character.active_by_user(%{user_id: user_id})
@@ -25,7 +27,9 @@ defmodule WandererAppWeb.AccessListsLive do
user_id: user_id,
access_lists: access_lists |> Enum.map(fn acl -> map_ui_acl(acl, nil) end),
characters: characters,
members: []
members: [],
members_page: 1,
members_per_page: @members_per_page
)}
end
@@ -39,7 +43,9 @@ defmodule WandererAppWeb.AccessListsLive do
allow_acl_creation: false,
access_lists: [],
characters: [],
members: []
members: [],
members_page: 1,
members_per_page: @members_per_page
)}
end
@@ -93,10 +99,8 @@ defmodule WandererAppWeb.AccessListsLive do
|> assign(:page_title, "Access Lists - Members")
|> assign(:selected_acl_id, acl_id)
|> assign(:access_list, access_list)
|> assign(
:members,
members
)
|> assign(:members, members)
|> assign(:members_page, 1)
else
_ ->
socket
@@ -324,6 +328,20 @@ defmodule WandererAppWeb.AccessListsLive do
{:noreply, assign(socket, form: form)}
end
@impl true
def handle_event("members_prev_page", _, socket) do
new_page = max(1, socket.assigns.members_page - 1)
{:noreply, assign(socket, :members_page, new_page)}
end
@impl true
def handle_event("members_next_page", _, socket) do
total_members = length(socket.assigns.members)
max_page = max(1, ceil(total_members / socket.assigns.members_per_page))
new_page = min(max_page, socket.assigns.members_page + 1)
{:noreply, assign(socket, :members_page, new_page)}
end
@impl true
def handle_event("noop", _, socket) do
{:noreply, socket}
@@ -719,6 +737,17 @@ defmodule WandererAppWeb.AccessListsLive do
acl |> Map.put(:selected, acl.id == selected_id)
end
defp paginated_members(members, page, per_page) do
members
|> Enum.sort_by(&{&1.role, &1.name}, &<=/2)
|> Enum.drop((page - 1) * per_page)
|> Enum.take(per_page)
end
defp total_pages(members, per_page) do
max(1, ceil(length(members) / per_page))
end
# Broadcast ACL update and invalidate map_characters cache for all maps using this ACL
# This ensures the tracking page shows updated members even when map server isn't running
defp broadcast_acl_updated(acl_id) do
@@ -742,7 +771,9 @@ defmodule WandererAppWeb.AccessListsLive do
end)
{:error, error} ->
Logger.warning("Failed to invalidate map_characters cache for ACL #{acl_id}: #{inspect(error)}")
Logger.warning(
"Failed to invalidate map_characters cache for ACL #{acl_id}: #{inspect(error)}"
)
end
end
end

View File

@@ -82,11 +82,14 @@
</h3>
<div
class="dropzone droppable draggable-dropzone--occupied flex flex-col gap-1 w-full rounded-none h-[calc(100vh-211px)] !overflow-y-auto"
class={[
"dropzone droppable draggable-dropzone--occupied flex flex-col gap-1 w-full rounded-none h-[calc(100vh-191px)] !overflow-y-auto",
classes("!h-[calc(100vh-240px)]": length(@members) > @members_per_page)
]}
id="acl_members"
>
<div
:for={member <- @members |> Enum.sort_by(&{&1.role, &1.name}, &<=/2)}
:for={member <- paginated_members(@members, @members_page, @members_per_page)}
draggable="true"
id={member.id}
class="draggable !p-1 h-10 cursor-move bg-black bg-opacity-25 hover:text-white"
@@ -113,10 +116,32 @@
</div>
</div>
</div>
<div>
<div :if={length(@members) > @members_per_page} class="flex items-center justify-between px-3 py-2 border-t border-gray-500 bg-black bg-opacity-25">
<span class="text-sm text-gray-400">
Page {@members_page} of {total_pages(@members, @members_per_page)} ({length(@members)} members)
</span>
<div class="flex gap-2">
<button
phx-click="members_prev_page"
disabled={@members_page <= 1}
class={"btn btn-sm btn-ghost " <> if(@members_page <= 1, do: "btn-disabled", else: "")}
>
<.icon name="hero-chevron-left" class="w-4 h-4" />
</button>
<button
phx-click="members_next_page"
disabled={@members_page >= total_pages(@members, @members_per_page)}
class={"btn btn-sm btn-ghost " <> if(@members_page >= total_pages(@members, @members_per_page), do: "btn-disabled", else: "")}
>
<.icon name="hero-chevron-right" class="w-4 h-4" />
</button>
</div>
</div>
</div>
<.link
:if={@selected_acl_id != "" and can_add_members?(@access_list, @current_user)}
class="btn mt-2 w-full btn-neutral rounded-none"
class="btn w-full btn-neutral rounded-none"
patch={~p"/access-lists/#{@selected_acl_id}/add-members"}
>
<.icon name="hero-plus-solid" class="w-6 h-6" />
@@ -129,6 +154,7 @@
<.icon name="hero-plus-solid" class="w-6 h-6" />
<h3 class="card-title text-center text-md">Add Members</h3>
</div>
</div>
</div>
</main>
</div>
@@ -153,10 +179,10 @@
placeholder="Select an owner"
options={Enum.map(@characters, fn character -> {character.label, character.id} end)}
/>
<!-- Divider between above inputs and the API key section -->
<hr class="my-4 border-gray-600" />
<!-- API Key Section with grid layout -->
<div class="mt-2">
<label class="block text-sm font-medium text-gray-200 mb-1">ACL API key</label>

View File

@@ -1,4 +1,4 @@
defmodule WandererAppWeb.UserActivity do
defmodule WandererAppWeb.UserActivityItem do
use WandererAppWeb, :live_component
use LiveViewEvents

View File

@@ -120,10 +120,16 @@ defmodule WandererAppWeb.MapConnectionsEventHandler do
{:ok, signatures} =
WandererApp.Api.MapSystemSignature.by_linked_system_id(solar_system_target_id)
signatures
|> Enum.filter(fn s ->
s.system_id == source_system.id
end)
filtered_signatures =
signatures
|> Enum.filter(fn s ->
s.system_id == source_system.id
end)
# Collect eve_ids for audit logging
deleted_eve_ids = Enum.map(filtered_signatures, & &1.eve_id)
filtered_signatures
|> Enum.each(fn s ->
if not is_nil(s.temporary_name) && s.temporary_name == target_system.temporary_name do
map_id
@@ -143,6 +149,17 @@ defmodule WandererAppWeb.MapConnectionsEventHandler do
|> WandererApp.Api.MapSystemSignature.destroy!()
end)
# Audit log signatures deleted with connection
if deleted_eve_ids != [] do
WandererApp.User.ActivityTracker.track_map_event(:signatures_removed, %{
character_id: main_character_id,
user_id: current_user_id,
map_id: map_id,
solar_system_id: solar_system_source_id,
signatures: deleted_eve_ids
})
end
WandererApp.Map.Server.Impl.broadcast!(
map_id,
:signatures_updated,

View File

@@ -3,8 +3,6 @@ defmodule WandererAppWeb.MapAuditLive do
require Logger
alias WandererAppWeb.UserActivity
def mount(
%{"slug" => map_slug, "period" => period, "activity" => activity} = _params,
_session,

View File

@@ -109,7 +109,7 @@
/>
</div>
<.live_component
module={UserActivity}
module={WandererAppWeb.UserActivityItem}
id="user-activity"
notify_to={self()}
can_undo_types={@can_undo_types}

View File

@@ -3,7 +3,7 @@ defmodule WandererApp.MixProject do
@source_url "https://github.com/wanderer-industries/wanderer"
@version "1.90.0"
@version "1.90.4"
def project do
[

View File

@@ -206,37 +206,55 @@ defmodule WandererApp.Map.Server.MapScopesTest do
assert ConnectionsImpl.is_connection_valid([:hi], @hs_system_id, @hs_system_id) == false
end
test "connection valid when at least one system matches a scope" do
# WH to HS: valid if either :wormholes or :hi is selected
test "wormhole border behavior: WH connections allow border k-space systems" do
# WH to HS with [:wormholes]: valid (wormhole border behavior)
# At least one system is WH, :wormholes is enabled -> border k-space allowed
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @hs_system_id) ==
true
assert ConnectionsImpl.is_connection_valid([:hi], @wh_system_id, @hs_system_id) == true
# WH to HS with [:hi] only: INVALID (no wormhole scope, WH doesn't match :hi)
# Neither system matches when we require both to match (no wormhole border behavior)
assert ConnectionsImpl.is_connection_valid([:hi], @wh_system_id, @hs_system_id) == false
# WH to WH: valid only if :wormholes is selected
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @c2_system_id) ==
true
assert ConnectionsImpl.is_connection_valid([:hi], @wh_system_id, @c2_system_id) == false
# HS to LS: valid if :hi or :low is selected
assert ConnectionsImpl.is_connection_valid([:hi], @hs_system_id, @ls_system_id) == true
assert ConnectionsImpl.is_connection_valid([:low], @hs_system_id, @ls_system_id) == true
assert ConnectionsImpl.is_connection_valid([:null], @hs_system_id, @ls_system_id) == false
end
test "connection with multiple scopes allows cross-space movement" do
# With [:wormholes, :hi], all of these should be valid:
# - WH to WH (wormholes matches)
# - HS to HS (hi matches)
# - WH to HS (either matches)
test "k-space connections require BOTH systems to match scopes" do
# HS to LS: requires BOTH to match, so single scope is not enough
assert ConnectionsImpl.is_connection_valid([:hi], @hs_system_id, @ls_system_id) == false
assert ConnectionsImpl.is_connection_valid([:low], @hs_system_id, @ls_system_id) == false
assert ConnectionsImpl.is_connection_valid([:null], @hs_system_id, @ls_system_id) == false
# HS to LS with [:hi, :low]: valid (both match)
assert ConnectionsImpl.is_connection_valid([:hi, :low], @hs_system_id, @ls_system_id) == true
# HS to HS: valid with [:hi] (both match)
assert ConnectionsImpl.is_connection_valid([:hi], @hs_system_id, 30_000_002) == true
# NS to NS: valid with [:null] (both match)
assert ConnectionsImpl.is_connection_valid([:null], @ns_system_id, @ns_system_id) == false
# (same system returns false)
end
test "connection with multiple scopes" do
# With [:wormholes, :hi]:
# - WH to WH: valid (both match :wormholes)
# - HS to HS: valid (both match :hi)
# - WH to HS: valid (wormhole border behavior - WH is wormhole, :wormholes enabled)
scopes = [:wormholes, :hi]
assert ConnectionsImpl.is_connection_valid(scopes, @wh_system_id, @c2_system_id) == true
assert ConnectionsImpl.is_connection_valid(scopes, @hs_system_id, 30_000_002) == true
assert ConnectionsImpl.is_connection_valid(scopes, @wh_system_id, @hs_system_id) == true
# But LS to NS should not be valid with [:wormholes, :hi]
# LS to NS should not be valid with [:wormholes, :hi] (neither is WH, neither matches)
assert ConnectionsImpl.is_connection_valid(scopes, @ls_system_id, @ns_system_id) == false
# HS to LS should not be valid with [:wormholes, :hi] (neither is WH, only HS matches)
assert ConnectionsImpl.is_connection_valid(scopes, @hs_system_id, @ls_system_id) == false
end
test "all scopes allows any connection" do
@@ -294,4 +312,127 @@ defmodule WandererApp.Map.Server.MapScopesTest do
assert ConnectionsImpl.is_prohibited_system_class?(25) == false
end
end
describe "maybe_add_system/5 scope filtering" do
alias WandererApp.Map.Server.SystemsImpl
test "returns :ok without filtering when scopes is nil" do
# When scopes is nil, should not filter (backward compatibility)
result = SystemsImpl.maybe_add_system("map_id", nil, nil, [])
assert result == :ok
end
test "returns :ok without filtering when scopes is empty list" do
# Empty scopes should not filter (let through)
result = SystemsImpl.maybe_add_system("map_id", nil, nil, [], [])
assert result == :ok
end
test "filters system when scopes provided and system doesn't match" do
# When scopes is [:wormholes] and system is Hi-Sec, should filter (return :ok without adding)
location = %{solar_system_id: @hs_system_id}
result = SystemsImpl.maybe_add_system("map_id", location, nil, [], [:wormholes])
# Returns :ok because system was filtered out (not an error, just skipped)
assert result == :ok
end
test "allows system through when scopes match (verified via can_add_location)" do
# When scopes is [:wormholes] and system is WH, filtering should allow it
# We test this via can_add_location which is what maybe_add_system uses internally
assert ConnectionsImpl.can_add_location([:wormholes], @wh_system_id) == true
assert ConnectionsImpl.can_add_location([:null], @ns_system_id) == true
assert ConnectionsImpl.can_add_location([:wormholes, :null], @wh_system_id) == true
assert ConnectionsImpl.can_add_location([:wormholes, :null], @ns_system_id) == true
end
end
describe "border system auto-addition behavior" do
# Tests that verify bordered systems are correctly auto-added ONLY for wormholes.
# Key behavior:
# - Wormhole border: WH to Hi-Sec with [:wormholes] -> BOTH added (border behavior)
# - K-space only: Null to Hi-Sec with [:wormholes, :null] -> REJECTED (no border for k-space)
# - K-space must match: both systems must match scopes when no wormhole involved
test "WORMHOLE BORDER: WH->Hi-Sec with [:wormholes] is VALID (border k-space added)" do
# Border case: moving from WH to k-space
# Valid because :wormholes enabled AND one system is WH
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @hs_system_id) == true
end
test "WORMHOLE BORDER: Hi-Sec->WH with [:wormholes] is VALID (border k-space added)" do
# Border case: moving from k-space to WH
# Valid because :wormholes enabled AND one system is WH
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, @wh_system_id) == true
end
test "K-SPACE ONLY: Hi-Sec->Hi-Sec with [:wormholes] is REJECTED" do
# No wormhole involved, neither matches :wormholes
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, 30_000_002) == false
end
test "K-SPACE ONLY: Null->Hi-Sec with [:wormholes, :null] is REJECTED (no border for k-space)" do
# Neither system is a wormhole, so no border behavior
# Null matches :null, but Hi-Sec doesn't match any scope -> BOTH must match
assert ConnectionsImpl.is_connection_valid([:wormholes, :null], @ns_system_id, @hs_system_id) ==
false
end
test "K-SPACE ONLY: Hi-Sec->Low-Sec with [:wormholes, :null] is REJECTED" do
# Neither Hi-Sec nor Low-Sec match [:wormholes, :null], no WH involved
assert ConnectionsImpl.is_connection_valid([:wormholes, :null], @hs_system_id, @ls_system_id) ==
false
end
test "K-SPACE ONLY: Low-Sec->Hi-Sec with [:low] is REJECTED (no border for k-space)" do
# Low-Sec matches :low, but Hi-Sec doesn't match
# No wormhole involved, so BOTH must match -> rejected
assert ConnectionsImpl.is_connection_valid([:low], @ls_system_id, @hs_system_id) == false
end
test "K-SPACE MATCH: Low-Sec->Low-Sec with [:low] is VALID (both match)" do
# Both systems match :low
assert ConnectionsImpl.is_connection_valid([:low], @ls_system_id, 30_000_101) == true
end
test "K-SPACE MATCH: Null->Null with [:null] is VALID (both match)" do
# Would need two different null-sec systems for this test
# Using same system returns false (same system check)
assert ConnectionsImpl.is_connection_valid([:null], @ns_system_id, @ns_system_id) == false
end
test "WORMHOLE BORDER: Pochven->WH with [:wormholes, :pochven] is VALID" do
# WH is wormhole, :wormholes enabled -> border behavior applies
assert ConnectionsImpl.is_connection_valid([:wormholes, :pochven], @pochven_id, @wh_system_id) ==
true
end
test "WORMHOLE BORDER: WH->Pochven with [:wormholes] is VALID (border k-space)" do
# WH is wormhole, :wormholes enabled -> border behavior, Pochven added as border
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @pochven_id) == true
end
test "border systems: WH->Hi-Sec->WH path with [:wormholes] scope" do
# Simulates a character path through k-space between WHs
# First jump: WH to Hi-Sec - valid (wormhole border)
assert ConnectionsImpl.is_connection_valid([:wormholes], @wh_system_id, @hs_system_id) == true
# Second jump: Hi-Sec to WH - valid (wormhole border)
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, @c2_system_id) == true
end
test "excluded path: k-space chain with [:wormholes] scope remains excluded" do
# If character moves within k-space (no WH involved), should be excluded
assert ConnectionsImpl.is_connection_valid([:wormholes], @hs_system_id, 30_000_002) == false
assert ConnectionsImpl.is_connection_valid([:wormholes], 30_000_002, @ls_system_id) == false
end
test "excluded path: Null->Hi-Sec->Low-Sec with [:wormholes, :null] - only Null tracked" do
# Character in Null (tracked) jumps to Hi-Sec (border - but NO wormhole!) -> REJECTED
# This is the key case: k-space to k-space should NOT add border systems
assert ConnectionsImpl.is_connection_valid([:wormholes, :null], @ns_system_id, @hs_system_id) ==
false
# Hi-Sec to Low-Sec also rejected (neither matches)
assert ConnectionsImpl.is_connection_valid([:wormholes, :null], @hs_system_id, @ls_system_id) ==
false
end
end
end