mirror of
https://github.com/wanderer-industries/wanderer
synced 2025-12-08 16:56:03 +00:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1394e2897e | ||
|
|
5117a1c5af | ||
|
|
3c62403f33 | ||
|
|
a4760f5162 | ||
|
|
b071070431 | ||
|
|
3bcb9628e7 | ||
|
|
e62c4cf5bf | ||
|
|
af46962ce4 | ||
|
|
0b0967830b | ||
|
|
172251a208 | ||
|
|
8a6fb63d55 | ||
|
|
9652959e5e | ||
|
|
825ef46d41 | ||
|
|
ad9f7c6b95 | ||
|
|
b960b5c149 | ||
|
|
0f092d21f9 | ||
|
|
031576caa6 | ||
|
|
7a97a96c42 | ||
|
|
2efb2daba0 | ||
|
|
4374c39924 | ||
|
|
15711495c7 | ||
|
|
236f803427 | ||
|
|
6772130f2a | ||
|
|
ddd72f3fac | ||
|
|
6e262835ef | ||
|
|
2f3b8ddc5f | ||
|
|
cea3a74b34 | ||
|
|
867941a233 | ||
|
|
3ff388a16d | ||
|
|
f4248e9ab9 | ||
|
|
507b3289c7 | ||
|
|
9e1dfc48d5 | ||
|
|
518cbc7b5d | ||
|
|
ccc8db0620 | ||
|
|
7cfb663efd | ||
|
|
e5103cc925 | ||
|
|
26458f5a19 | ||
|
|
79d5ec6caf | ||
|
|
034d461ab6 | ||
|
|
2e9c1c170c | ||
|
|
24ad3b2c61 | ||
|
|
288f55dc2f |
6
.github/workflows/docker-arm.yml
vendored
6
.github/workflows/docker-arm.yml
vendored
@@ -123,9 +123,9 @@ jobs:
|
||||
id: get-content
|
||||
with:
|
||||
stringToTruncate: |
|
||||
📣 Wanderer **ARM** (https://hub.docker.com/repository/docker/wandererltd/community-edition-arm/general) release available 🎉
|
||||
📣 Wanderer **ARM** release available 🎉
|
||||
|
||||
**Version**: ${{ steps.get-latest-tag.outputs.tag }}
|
||||
**Version**: :${{ steps.get-latest-tag.outputs.tag }}
|
||||
|
||||
${{ steps.extract-changelog.outputs.body }}
|
||||
maxLength: 500
|
||||
@@ -184,4 +184,4 @@ jobs:
|
||||
uses: tsickert/discord-webhook@v5.3.0
|
||||
with:
|
||||
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
content: Release notes ${{ needs.docker.outputs.release-notes }}
|
||||
content: ${{ needs.docker.outputs.release-notes }}
|
||||
|
||||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -184,4 +184,4 @@ jobs:
|
||||
uses: tsickert/discord-webhook@v5.3.0
|
||||
with:
|
||||
webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
content: Release notes ${{ needs.docker.outputs.release-notes }}
|
||||
content: ${{ needs.docker.outputs.release-notes }}
|
||||
|
||||
96
CHANGELOG.md
96
CHANGELOG.md
@@ -2,6 +2,102 @@
|
||||
|
||||
<!-- changelog -->
|
||||
|
||||
## [v1.76.13](https://github.com/wanderer-industries/wanderer/compare/v1.76.12...v1.76.13) (2025-08-27)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Core: Fixed maps start timeout
|
||||
|
||||
## [v1.76.12](https://github.com/wanderer-industries/wanderer/compare/v1.76.11...v1.76.12) (2025-08-20)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Core: Reduced ESI api calls to update character corp/ally info
|
||||
|
||||
## [v1.76.11](https://github.com/wanderer-industries/wanderer/compare/v1.76.10...v1.76.11) (2025-08-20)
|
||||
|
||||
|
||||
|
||||
|
||||
## [v1.76.10](https://github.com/wanderer-industries/wanderer/compare/v1.76.9...v1.76.10) (2025-08-18)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Core: Added character trackers start queue
|
||||
|
||||
## [v1.76.9](https://github.com/wanderer-industries/wanderer/compare/v1.76.8...v1.76.9) (2025-08-18)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* default signature types not being shown
|
||||
|
||||
## [v1.76.8](https://github.com/wanderer-industries/wanderer/compare/v1.76.7...v1.76.8) (2025-08-17)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Core: added DB connection default timeouts
|
||||
|
||||
## [v1.76.7](https://github.com/wanderer-industries/wanderer/compare/v1.76.6...v1.76.7) (2025-08-16)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Core: Fixed auth redirect URL
|
||||
|
||||
## [v1.76.6](https://github.com/wanderer-industries/wanderer/compare/v1.76.5...v1.76.6) (2025-08-15)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* empty subscriptions for sse
|
||||
|
||||
## [v1.76.5](https://github.com/wanderer-industries/wanderer/compare/v1.76.4...v1.76.5) (2025-08-15)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* Core: fixed tracking paused issues, fixed user activity data
|
||||
|
||||
## [v1.76.4](https://github.com/wanderer-industries/wanderer/compare/v1.76.3...v1.76.4) (2025-08-14)
|
||||
|
||||
|
||||
|
||||
|
||||
### Bug Fixes:
|
||||
|
||||
* timestamp errors for sse and tracking
|
||||
|
||||
## [v1.76.3](https://github.com/wanderer-industries/wanderer/compare/v1.76.2...v1.76.3) (2025-08-14)
|
||||
|
||||
|
||||
|
||||
|
||||
## [v1.76.2](https://github.com/wanderer-industries/wanderer/compare/v1.76.1...v1.76.2) (2025-08-14)
|
||||
|
||||
|
||||
|
||||
|
||||
## [v1.76.1](https://github.com/wanderer-industries/wanderer/compare/v1.76.0...v1.76.1) (2025-08-13)
|
||||
|
||||
|
||||
|
||||
@@ -11,11 +11,13 @@ config :wanderer_app, WandererAppWeb.Endpoint,
|
||||
config :wanderer_app, WandererApp.Repo,
|
||||
ssl: false,
|
||||
stacktrace: true,
|
||||
show_sensitive_data_on_connection_error: true,
|
||||
show_sensitive_data_on_connection_error: false,
|
||||
pool_size: 15,
|
||||
migration_timestamps: [type: :utc_datetime_usec],
|
||||
migration_lock: nil,
|
||||
queue_target: 5000
|
||||
queue_target: 5000,
|
||||
queue_interval: 1000,
|
||||
checkout_timeout: 15000
|
||||
|
||||
# Configures Swoosh API Client
|
||||
config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: WandererApp.Finch
|
||||
|
||||
@@ -124,7 +124,7 @@ defmodule WandererApp.Api.Character do
|
||||
update :update_corporation do
|
||||
require_atomic? false
|
||||
|
||||
accept([:corporation_id, :corporation_name, :corporation_ticker, :alliance_id])
|
||||
accept([:corporation_id, :corporation_name, :corporation_ticker])
|
||||
end
|
||||
|
||||
update :update_alliance do
|
||||
|
||||
@@ -49,11 +49,13 @@ defmodule WandererApp.Character.Activity do
|
||||
"""
|
||||
def process_character_activity(map_id, current_user) do
|
||||
with {:ok, map_user_settings} <- get_map_user_settings(map_id, current_user.id),
|
||||
raw_activity <- WandererApp.Map.get_character_activity(map_id),
|
||||
{:ok, raw_activity} <- WandererApp.Map.get_character_activity(map_id),
|
||||
{:ok, user_characters} <-
|
||||
WandererApp.Api.Character.active_by_user(%{user_id: current_user.id}) do
|
||||
result = process_activity_data(raw_activity, map_user_settings, user_characters)
|
||||
result
|
||||
process_activity_data(raw_activity, map_user_settings, user_characters)
|
||||
else
|
||||
_ ->
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ defmodule WandererApp.Character.Tracker do
|
||||
defstruct [
|
||||
:character_id,
|
||||
:alliance_id,
|
||||
:corporation_id,
|
||||
:opts,
|
||||
server_online: true,
|
||||
start_time: nil,
|
||||
@@ -21,6 +22,8 @@ defmodule WandererApp.Character.Tracker do
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
character_id: integer,
|
||||
alliance_id: integer,
|
||||
corporation_id: integer,
|
||||
opts: map,
|
||||
server_online: boolean,
|
||||
start_time: DateTime.t(),
|
||||
@@ -34,10 +37,10 @@ defmodule WandererApp.Character.Tracker do
|
||||
}
|
||||
|
||||
@pause_tracking_timeout :timer.minutes(60 * 10)
|
||||
@offline_timeout :timer.minutes(5)
|
||||
@online_error_timeout :timer.minutes(2)
|
||||
@ship_error_timeout :timer.minutes(2)
|
||||
@location_error_timeout :timer.minutes(2)
|
||||
@offline_timeout :timer.minutes(10)
|
||||
@online_error_timeout :timer.minutes(10)
|
||||
@ship_error_timeout :timer.minutes(10)
|
||||
@location_error_timeout :timer.minutes(10)
|
||||
@online_forbidden_ttl :timer.seconds(7)
|
||||
@online_limit_ttl :timer.seconds(7)
|
||||
@forbidden_ttl :timer.seconds(5)
|
||||
@@ -49,8 +52,15 @@ defmodule WandererApp.Character.Tracker do
|
||||
def new(args), do: __struct__(args)
|
||||
|
||||
def init(args) do
|
||||
character_id = args[:character_id]
|
||||
|
||||
{:ok, %{corporation_id: corporation_id, alliance_id: alliance_id}} =
|
||||
WandererApp.Character.get_character(character_id)
|
||||
|
||||
%{
|
||||
character_id: args[:character_id],
|
||||
character_id: character_id,
|
||||
corporation_id: corporation_id,
|
||||
alliance_id: alliance_id,
|
||||
start_time: DateTime.utc_now(),
|
||||
opts: args
|
||||
}
|
||||
@@ -101,6 +111,7 @@ defmodule WandererApp.Character.Tracker do
|
||||
|
||||
if duration >= timeout do
|
||||
pause_tracking(character_id)
|
||||
WandererApp.Cache.delete("character:#{character_id}:#{type}_error_time")
|
||||
|
||||
:ok
|
||||
else
|
||||
@@ -113,15 +124,14 @@ defmodule WandererApp.Character.Tracker do
|
||||
if WandererApp.Character.can_pause_tracking?(character_id) &&
|
||||
not WandererApp.Cache.has_key?("character:#{character_id}:tracking_paused") do
|
||||
# Log character tracking statistics before pausing
|
||||
{:ok, character_state} = WandererApp.Character.get_character_state(character_id)
|
||||
Logger.debug(fn ->
|
||||
{:ok, character_state} = WandererApp.Character.get_character_state(character_id)
|
||||
|
||||
Logger.warning(
|
||||
"CHARACTER_TRACKING_PAUSED: Character tracking paused due to sustained errors",
|
||||
character_id: character_id,
|
||||
"CHARACTER_TRACKING_PAUSED: Character tracking paused due to sustained errors: #{inspect(character_id: character_id,
|
||||
active_maps: length(character_state.active_maps),
|
||||
is_online: character_state.is_online,
|
||||
tracking_duration_minutes: get_tracking_duration_minutes(character_id)
|
||||
)
|
||||
tracking_duration_minutes: get_tracking_duration_minutes(character_id))}"
|
||||
end)
|
||||
|
||||
WandererApp.Cache.delete("character:#{character_id}:online_forbidden")
|
||||
WandererApp.Cache.delete("character:#{character_id}:online_error_time")
|
||||
@@ -193,7 +203,7 @@ defmodule WandererApp.Character.Tracker do
|
||||
access_token: access_token,
|
||||
character_id: character_id
|
||||
) do
|
||||
{:ok, online} ->
|
||||
{:ok, online} when is_map(online) ->
|
||||
online = get_online(online)
|
||||
|
||||
if online.online == true do
|
||||
@@ -258,7 +268,7 @@ defmodule WandererApp.Character.Tracker do
|
||||
character_id: character_id
|
||||
})
|
||||
|
||||
Logger.warning("ESI_ERROR: Character online tracking failed",
|
||||
Logger.warning("ESI_ERROR: Character online tracking failed #{inspect(error)}",
|
||||
character_id: character_id,
|
||||
tracking_pool: tracking_pool,
|
||||
error_type: error,
|
||||
@@ -388,12 +398,21 @@ defmodule WandererApp.Character.Tracker do
|
||||
{:ok, %{eve_id: eve_id, tracking_pool: tracking_pool}} =
|
||||
WandererApp.Character.get_character(character_id)
|
||||
|
||||
case WandererApp.Esi.get_character_info(eve_id) do
|
||||
{:ok, _info} ->
|
||||
character_eve_id = eve_id |> String.to_integer()
|
||||
|
||||
case WandererApp.Esi.post_characters_affiliation([character_eve_id]) do
|
||||
{:ok, [character_aff_info]} when not is_nil(character_aff_info) ->
|
||||
{:ok, character_state} = WandererApp.Character.get_character_state(character_id)
|
||||
|
||||
update = maybe_update_corporation(character_state, eve_id |> String.to_integer())
|
||||
WandererApp.Character.update_character_state(character_id, update)
|
||||
alliance_id = character_aff_info |> Map.get("alliance_id")
|
||||
corporation_id = character_aff_info |> Map.get("corporation_id")
|
||||
|
||||
updated_state =
|
||||
character_state
|
||||
|> maybe_update_corporation(corporation_id)
|
||||
|> maybe_update_alliance(alliance_id)
|
||||
|
||||
WandererApp.Character.update_character_state(character_id, updated_state)
|
||||
|
||||
:ok
|
||||
|
||||
@@ -975,7 +994,38 @@ defmodule WandererApp.Character.Tracker do
|
||||
end
|
||||
end
|
||||
|
||||
defp update_alliance(%{character_id: character_id} = state, alliance_id) do
|
||||
defp maybe_update_alliance(
|
||||
%{character_id: character_id, alliance_id: old_alliance_id} = state,
|
||||
alliance_id
|
||||
)
|
||||
when old_alliance_id != alliance_id and is_nil(alliance_id) do
|
||||
{:ok, character} = WandererApp.Character.get_character(character_id)
|
||||
|
||||
character_update = %{
|
||||
alliance_id: nil,
|
||||
alliance_name: nil,
|
||||
alliance_ticker: nil
|
||||
}
|
||||
|
||||
{:ok, _character} =
|
||||
Character.update_alliance(character, character_update)
|
||||
|
||||
WandererApp.Character.update_character(character_id, character_update)
|
||||
|
||||
@pubsub_client.broadcast(
|
||||
WandererApp.PubSub,
|
||||
"character:#{character_id}:alliance",
|
||||
{:character_alliance, {character_id, character_update}}
|
||||
)
|
||||
|
||||
state
|
||||
end
|
||||
|
||||
defp maybe_update_alliance(
|
||||
%{character_id: character_id, alliance_id: old_alliance_id} = state,
|
||||
alliance_id
|
||||
)
|
||||
when old_alliance_id != alliance_id do
|
||||
(WandererApp.Cache.has_key?("character:#{character_id}:online_forbidden") ||
|
||||
WandererApp.Cache.has_key?("character:#{character_id}:tracking_paused"))
|
||||
|> case do
|
||||
@@ -1015,7 +1065,13 @@ defmodule WandererApp.Character.Tracker do
|
||||
end
|
||||
end
|
||||
|
||||
defp update_corporation(%{character_id: character_id} = state, corporation_id) do
|
||||
defp maybe_update_alliance(state, _alliance_id), do: state
|
||||
|
||||
defp maybe_update_corporation(
|
||||
%{character_id: character_id, corporation_id: old_corporation_id} = state,
|
||||
corporation_id
|
||||
)
|
||||
when old_corporation_id != corporation_id do
|
||||
(WandererApp.Cache.has_key?("character:#{character_id}:online_forbidden") ||
|
||||
WandererApp.Cache.has_key?("character:#{character_id}:tracking_paused"))
|
||||
|> case do
|
||||
@@ -1027,16 +1083,13 @@ defmodule WandererApp.Character.Tracker do
|
||||
|> WandererApp.Esi.get_corporation_info()
|
||||
|> case do
|
||||
{:ok, %{"name" => corporation_name, "ticker" => corporation_ticker} = corporation_info} ->
|
||||
alliance_id = Map.get(corporation_info, "alliance_id")
|
||||
|
||||
{:ok, character} =
|
||||
WandererApp.Character.get_character(character_id)
|
||||
|
||||
character_update = %{
|
||||
corporation_id: corporation_id,
|
||||
corporation_name: corporation_name,
|
||||
corporation_ticker: corporation_ticker,
|
||||
alliance_id: alliance_id
|
||||
corporation_ticker: corporation_ticker
|
||||
}
|
||||
|
||||
{:ok, _character} =
|
||||
@@ -1057,8 +1110,7 @@ defmodule WandererApp.Character.Tracker do
|
||||
)
|
||||
|
||||
state
|
||||
|> Map.merge(%{alliance_id: alliance_id, corporation_id: corporation_id})
|
||||
|> maybe_update_alliance()
|
||||
|> Map.merge(%{corporation_id: corporation_id})
|
||||
|
||||
error ->
|
||||
Logger.warning(
|
||||
@@ -1072,6 +1124,8 @@ defmodule WandererApp.Character.Tracker do
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_update_corporation(state, _corporation_id), do: state
|
||||
|
||||
defp maybe_update_ship(
|
||||
%{
|
||||
character_id: character_id
|
||||
@@ -1153,58 +1207,6 @@ defmodule WandererApp.Character.Tracker do
|
||||
structure_id != new_structure_id ||
|
||||
station_id != new_station_id
|
||||
|
||||
defp maybe_update_corporation(
|
||||
state,
|
||||
character_eve_id
|
||||
)
|
||||
when not is_nil(character_eve_id) and is_integer(character_eve_id) do
|
||||
case WandererApp.Esi.post_characters_affiliation([character_eve_id]) do
|
||||
{:ok, [character_aff_info]} when not is_nil(character_aff_info) ->
|
||||
update_corporation(state, character_aff_info |> Map.get("corporation_id"))
|
||||
|
||||
_error ->
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_update_corporation(
|
||||
state,
|
||||
_info
|
||||
),
|
||||
do: state
|
||||
|
||||
defp maybe_update_alliance(
|
||||
%{character_id: character_id, alliance_id: alliance_id} =
|
||||
state
|
||||
) do
|
||||
case alliance_id do
|
||||
nil ->
|
||||
{:ok, character} = WandererApp.Character.get_character(character_id)
|
||||
|
||||
character_update = %{
|
||||
alliance_id: nil,
|
||||
alliance_name: nil,
|
||||
alliance_ticker: nil
|
||||
}
|
||||
|
||||
{:ok, _character} =
|
||||
Character.update_alliance(character, character_update)
|
||||
|
||||
WandererApp.Character.update_character(character_id, character_update)
|
||||
|
||||
@pubsub_client.broadcast(
|
||||
WandererApp.PubSub,
|
||||
"character:#{character_id}:alliance",
|
||||
{:character_alliance, {character_id, character_update}}
|
||||
)
|
||||
|
||||
state
|
||||
|
||||
_ ->
|
||||
update_alliance(state, alliance_id)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_update_wallet(
|
||||
%{character_id: character_id} =
|
||||
state,
|
||||
|
||||
@@ -12,6 +12,7 @@ defmodule WandererApp.Character.TrackerManager.Impl do
|
||||
opts: map
|
||||
}
|
||||
|
||||
@check_start_queue_interval :timer.seconds(1)
|
||||
@garbage_collection_interval :timer.minutes(15)
|
||||
@untrack_characters_interval :timer.minutes(1)
|
||||
@inactive_character_timeout :timer.minutes(10)
|
||||
@@ -23,6 +24,7 @@ defmodule WandererApp.Character.TrackerManager.Impl do
|
||||
def new(args), do: __struct__(args)
|
||||
|
||||
def init(args) do
|
||||
Process.send_after(self(), :check_start_queue, @check_start_queue_interval)
|
||||
Process.send_after(self(), :garbage_collect, @garbage_collection_interval)
|
||||
Process.send_after(self(), :untrack_characters, @untrack_characters_interval)
|
||||
|
||||
@@ -46,25 +48,19 @@ defmodule WandererApp.Character.TrackerManager.Impl do
|
||||
end
|
||||
|
||||
def start_tracking(state, character_id, opts) do
|
||||
with {:ok, characters} <- WandererApp.Cache.lookup("tracked_characters", []),
|
||||
false <- Enum.member?(characters, character_id) do
|
||||
Logger.debug(fn -> "Start character tracker: #{inspect(character_id)}" end)
|
||||
if not WandererApp.Cache.has_key?("#{character_id}:track_requested") do
|
||||
WandererApp.Cache.insert(
|
||||
"#{character_id}:track_requested",
|
||||
true
|
||||
)
|
||||
|
||||
tracked_characters = [character_id | characters] |> Enum.uniq()
|
||||
WandererApp.Cache.insert("tracked_characters", tracked_characters)
|
||||
|
||||
WandererApp.Character.update_character(character_id, %{online: false})
|
||||
|
||||
WandererApp.Character.update_character_state(character_id, %{
|
||||
is_online: false
|
||||
})
|
||||
|
||||
WandererApp.Character.TrackerPoolDynamicSupervisor.start_tracking(character_id)
|
||||
|
||||
WandererApp.TaskWrapper.start_link(WandererApp.Character, :update_character_state, [
|
||||
character_id,
|
||||
%{opts: opts}
|
||||
])
|
||||
WandererApp.Cache.insert_or_update(
|
||||
"track_characters_queue",
|
||||
[character_id],
|
||||
fn existing ->
|
||||
[character_id | existing] |> Enum.uniq()
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
state
|
||||
@@ -178,6 +174,21 @@ defmodule WandererApp.Character.TrackerManager.Impl do
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
:check_start_queue,
|
||||
state
|
||||
) do
|
||||
Process.send_after(self(), :check_start_queue, @check_start_queue_interval)
|
||||
{:ok, track_characters_queue} = WandererApp.Cache.lookup("track_characters_queue", [])
|
||||
|
||||
track_characters_queue
|
||||
|> Enum.each(fn character_id ->
|
||||
track_character(character_id, %{})
|
||||
end)
|
||||
|
||||
state
|
||||
end
|
||||
|
||||
def handle_info(
|
||||
:garbage_collect,
|
||||
state
|
||||
@@ -294,8 +305,56 @@ defmodule WandererApp.Character.TrackerManager.Impl do
|
||||
state
|
||||
end
|
||||
|
||||
def handle_info(_event, state),
|
||||
do: state
|
||||
def track_character(character_id, opts) do
|
||||
with {:ok, characters} <- WandererApp.Cache.lookup("tracked_characters", []),
|
||||
false <- Enum.member?(characters, character_id) do
|
||||
Logger.debug(fn -> "Start character tracker: #{inspect(character_id)}" end)
|
||||
|
||||
WandererApp.Cache.insert_or_update(
|
||||
"tracked_characters",
|
||||
[character_id],
|
||||
fn existing ->
|
||||
[character_id | existing] |> Enum.uniq()
|
||||
end
|
||||
)
|
||||
|
||||
WandererApp.Cache.insert_or_update(
|
||||
"track_characters_queue",
|
||||
[],
|
||||
fn existing ->
|
||||
existing
|
||||
|> Enum.reject(fn c_id -> c_id == character_id end)
|
||||
end
|
||||
)
|
||||
|
||||
WandererApp.Cache.delete("#{character_id}:track_requested")
|
||||
|
||||
WandererApp.Character.update_character(character_id, %{online: false})
|
||||
|
||||
WandererApp.Character.update_character_state(character_id, %{
|
||||
is_online: false
|
||||
})
|
||||
|
||||
WandererApp.Character.TrackerPoolDynamicSupervisor.start_tracking(character_id)
|
||||
|
||||
WandererApp.TaskWrapper.start_link(WandererApp.Character, :update_character_state, [
|
||||
character_id,
|
||||
%{opts: opts}
|
||||
])
|
||||
else
|
||||
_ ->
|
||||
WandererApp.Cache.insert_or_update(
|
||||
"track_characters_queue",
|
||||
[],
|
||||
fn existing ->
|
||||
existing
|
||||
|> Enum.reject(fn c_id -> c_id == character_id end)
|
||||
end
|
||||
)
|
||||
|
||||
WandererApp.Cache.delete("#{character_id}:track_requested")
|
||||
end
|
||||
end
|
||||
|
||||
def character_is_present(map_id, character_id) do
|
||||
{:ok, presence_character_ids} =
|
||||
|
||||
@@ -23,7 +23,7 @@ defmodule WandererApp.Character.TrackerPool do
|
||||
@check_ship_errors_interval :timer.minutes(1)
|
||||
@check_location_errors_interval :timer.minutes(1)
|
||||
@update_ship_interval :timer.seconds(2)
|
||||
@update_info_interval :timer.minutes(1)
|
||||
@update_info_interval :timer.minutes(2)
|
||||
@update_wallet_interval :timer.minutes(1)
|
||||
|
||||
@logger Application.compile_env(:wanderer_app, :logger)
|
||||
@@ -124,7 +124,7 @@ defmodule WandererApp.Character.TrackerPool do
|
||||
Process.send_after(self(), :check_online_errors, :timer.seconds(60))
|
||||
Process.send_after(self(), :check_ship_errors, :timer.seconds(90))
|
||||
Process.send_after(self(), :check_location_errors, :timer.seconds(120))
|
||||
Process.send_after(self(), :check_offline_characters, @check_offline_characters_interval)
|
||||
# Process.send_after(self(), :check_offline_characters, @check_offline_characters_interval)
|
||||
Process.send_after(self(), :update_location, 300)
|
||||
Process.send_after(self(), :update_ship, 500)
|
||||
Process.send_after(self(), :update_info, 1500)
|
||||
|
||||
@@ -287,8 +287,8 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
opts: [ttl: @ttl]
|
||||
)
|
||||
def get_alliance_info(eve_id, opts \\ []) do
|
||||
case _get_alliance_info(eve_id, "", opts) do
|
||||
{:ok, result} -> {:ok, result |> Map.put("eve_id", eve_id)}
|
||||
case get_alliance_info(eve_id, "", opts) do
|
||||
{:ok, result} when is_map(result) -> {:ok, result |> Map.put("eve_id", eve_id)}
|
||||
{:error, error} -> {:error, error}
|
||||
error -> error
|
||||
end
|
||||
@@ -309,8 +309,8 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
opts: [ttl: @ttl]
|
||||
)
|
||||
def get_corporation_info(eve_id, opts \\ []) do
|
||||
case _get_corporation_info(eve_id, "", opts) do
|
||||
{:ok, result} -> {:ok, result |> Map.put("eve_id", eve_id)}
|
||||
case get_corporation_info(eve_id, "", opts) do
|
||||
{:ok, result} when is_map(result) -> {:ok, result |> Map.put("eve_id", eve_id)}
|
||||
{:error, error} -> {:error, error}
|
||||
error -> error
|
||||
end
|
||||
@@ -327,7 +327,7 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
opts,
|
||||
@cache_opts
|
||||
) do
|
||||
{:ok, result} -> {:ok, result |> Map.put("eve_id", eve_id)}
|
||||
{:ok, result} when is_map(result) -> {:ok, result |> Map.put("eve_id", eve_id)}
|
||||
{:error, error} -> {:error, error}
|
||||
error -> error
|
||||
end
|
||||
@@ -434,7 +434,7 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
|
||||
defp get_auth_opts(opts), do: [auth: {:bearer, opts[:access_token]}]
|
||||
|
||||
defp _get_alliance_info(alliance_eve_id, info_path, opts),
|
||||
defp get_alliance_info(alliance_eve_id, info_path, opts),
|
||||
do:
|
||||
get(
|
||||
"/alliances/#{alliance_eve_id}/#{info_path}",
|
||||
@@ -442,7 +442,7 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
@cache_opts
|
||||
)
|
||||
|
||||
defp _get_corporation_info(corporation_eve_id, info_path, opts),
|
||||
defp get_corporation_info(corporation_eve_id, info_path, opts),
|
||||
do:
|
||||
get(
|
||||
"/corporations/#{corporation_eve_id}/#{info_path}",
|
||||
@@ -830,7 +830,8 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
expires_at,
|
||||
scopes
|
||||
) do
|
||||
time_since_expiry = DateTime.diff(DateTime.utc_now(), expires_at, :second)
|
||||
expires_at_datetime = DateTime.from_unix!(expires_at)
|
||||
time_since_expiry = DateTime.diff(DateTime.utc_now(), expires_at_datetime, :second)
|
||||
|
||||
Logger.warning("TOKEN_REFRESH_FAILED: Invalid grant error during token refresh",
|
||||
character_id: character_id,
|
||||
@@ -857,7 +858,8 @@ defmodule WandererApp.Esi.ApiClient do
|
||||
expires_at,
|
||||
scopes
|
||||
) do
|
||||
time_since_expiry = DateTime.diff(DateTime.utc_now(), expires_at, :second)
|
||||
expires_at_datetime = DateTime.from_unix!(expires_at)
|
||||
time_since_expiry = DateTime.diff(DateTime.utc_now(), expires_at_datetime, :second)
|
||||
|
||||
Logger.warning("TOKEN_REFRESH_FAILED: Connection refused during token refresh",
|
||||
character_id: character_id,
|
||||
|
||||
@@ -51,7 +51,7 @@ defmodule WandererApp.ExternalEvents.Event do
|
||||
def new(map_id, event_type, payload) when is_binary(map_id) and is_map(payload) do
|
||||
if valid_event_type?(event_type) do
|
||||
%__MODULE__{
|
||||
id: Ulid.generate(System.system_time(:millisecond)),
|
||||
id: Ecto.ULID.generate(System.system_time(:millisecond)),
|
||||
map_id: map_id,
|
||||
type: event_type,
|
||||
payload: payload,
|
||||
|
||||
@@ -448,7 +448,7 @@ defmodule WandererApp.ExternalEvents.JsonApiFormatter do
|
||||
"connected" ->
|
||||
%{
|
||||
"type" => "connection_status",
|
||||
"id" => event["id"] || Ulid.generate(),
|
||||
"id" => event["id"] || Ecto.ULID.generate(),
|
||||
"attributes" => %{
|
||||
"status" => "connected",
|
||||
"server_time" => payload["server_time"],
|
||||
@@ -465,7 +465,7 @@ defmodule WandererApp.ExternalEvents.JsonApiFormatter do
|
||||
# Use existing payload structure but wrap it in JSON:API format
|
||||
%{
|
||||
"type" => "events",
|
||||
"id" => event["id"] || Ulid.generate(),
|
||||
"id" => event["id"] || Ecto.ULID.generate(),
|
||||
"attributes" => payload,
|
||||
"relationships" => %{
|
||||
"map" => %{
|
||||
|
||||
@@ -248,6 +248,6 @@ defmodule WandererApp.ExternalEvents.MapEventRelay do
|
||||
defp datetime_to_ulid(datetime) do
|
||||
timestamp = DateTime.to_unix(datetime, :millisecond)
|
||||
# Create a ULID with the timestamp (rest will be zeros for comparison)
|
||||
Ulid.generate(timestamp)
|
||||
Ecto.ULID.generate(timestamp)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,6 +8,19 @@ defmodule WandererApp.Map.Manager do
|
||||
require Logger
|
||||
|
||||
alias WandererApp.Map.Server
|
||||
alias WandererApp.Map.ServerSupervisor
|
||||
alias WandererApp.Api.MapSystemSignature
|
||||
|
||||
@maps_start_per_second 10
|
||||
@maps_start_interval 1000
|
||||
@maps_queue :maps_queue
|
||||
@garbage_collection_interval :timer.hours(1)
|
||||
@check_maps_queue_interval :timer.seconds(1)
|
||||
@signatures_cleanup_interval :timer.minutes(30)
|
||||
@delete_after_minutes 30
|
||||
|
||||
@pings_cleanup_interval :timer.minutes(10)
|
||||
@pings_expire_minutes 60
|
||||
|
||||
# Test-aware async task runner
|
||||
defp safe_async_task(fun) do
|
||||
@@ -25,20 +38,6 @@ defmodule WandererApp.Map.Manager do
|
||||
end
|
||||
end
|
||||
|
||||
alias WandererApp.Map.ServerSupervisor
|
||||
alias WandererApp.Api.MapSystemSignature
|
||||
|
||||
@maps_start_per_second 5
|
||||
@maps_start_interval 1000
|
||||
@maps_queue :maps_queue
|
||||
@garbage_collection_interval :timer.hours(1)
|
||||
@check_maps_queue_interval :timer.seconds(1)
|
||||
@signatures_cleanup_interval :timer.minutes(30)
|
||||
@delete_after_minutes 30
|
||||
|
||||
@pings_cleanup_interval :timer.minutes(10)
|
||||
@pings_expire_minutes 60
|
||||
|
||||
def start_map(map_id) when is_binary(map_id),
|
||||
do: WandererApp.Queue.push_uniq(@maps_queue, map_id)
|
||||
|
||||
@@ -247,22 +246,29 @@ defmodule WandererApp.Map.Manager do
|
||||
Logger.debug(fn -> "All maps started" end)
|
||||
else
|
||||
# In production, run async as normal
|
||||
tasks =
|
||||
for chunk <- chunks do
|
||||
task =
|
||||
Task.async(fn ->
|
||||
chunk
|
||||
|> Enum.map(&start_map_server/1)
|
||||
end)
|
||||
chunks
|
||||
|> Task.async_stream(
|
||||
fn chunk ->
|
||||
chunk
|
||||
|> Enum.map(&start_map_server/1)
|
||||
|
||||
:timer.sleep(@maps_start_interval)
|
||||
end,
|
||||
max_concurrency: System.schedulers_online(),
|
||||
on_timeout: :kill_task,
|
||||
timeout: :timer.seconds(60)
|
||||
)
|
||||
|> Enum.each(fn result ->
|
||||
case result do
|
||||
{:ok, _} ->
|
||||
:ok
|
||||
|
||||
task
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end)
|
||||
|
||||
Logger.debug(fn -> "Waiting for maps to start" end)
|
||||
Task.await_many(tasks)
|
||||
Logger.debug(fn -> "All maps started" end)
|
||||
Logger.info(fn -> "All maps started" end)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -373,6 +373,8 @@ defmodule WandererApp.Map.Server.CharactersImpl do
|
||||
{:ok, character} =
|
||||
WandererApp.Character.get_map_character(map_id, character_id, not_present: true)
|
||||
|
||||
WandererApp.Cache.delete("character:#{character.id}:tracking_paused")
|
||||
|
||||
add_character(%{map_id: map_id}, character, true)
|
||||
|
||||
WandererApp.Character.TrackerManager.update_track_settings(character_id, %{
|
||||
|
||||
@@ -39,7 +39,7 @@ defmodule WandererApp.SecurityAudit do
|
||||
}
|
||||
|
||||
# Store in database
|
||||
store_audit_entry(audit_entry)
|
||||
# store_audit_entry(audit_entry)
|
||||
|
||||
# Send to telemetry for monitoring
|
||||
emit_telemetry_event(audit_entry)
|
||||
@@ -489,11 +489,11 @@ defmodule WandererApp.SecurityAudit do
|
||||
|
||||
defp store_audit_entry(audit_entry) do
|
||||
# Handle async processing if enabled
|
||||
if async_enabled?() do
|
||||
WandererApp.SecurityAudit.AsyncProcessor.log_event(audit_entry)
|
||||
else
|
||||
do_store_audit_entry(audit_entry)
|
||||
end
|
||||
# if async_enabled?() do
|
||||
# WandererApp.SecurityAudit.AsyncProcessor.log_event(audit_entry)
|
||||
# else
|
||||
# do_store_audit_entry(audit_entry)
|
||||
# end
|
||||
end
|
||||
|
||||
@doc false
|
||||
|
||||
@@ -195,7 +195,7 @@ defmodule WandererApp.Ueberauth.Strategy.Eve do
|
||||
tracking_pool = WandererApp.Character.TrackingConfigUtils.get_active_pool!()
|
||||
|
||||
base_options = [
|
||||
redirect_uri: callback_url(conn),
|
||||
redirect_uri: "#{WandererApp.Env.base_url()}/auth/eve/callback",
|
||||
with_wallet: with_wallet,
|
||||
is_admin?: is_admin?,
|
||||
tracking_pool: tracking_pool
|
||||
|
||||
@@ -52,11 +52,7 @@ defmodule WandererAppWeb.Api.EventsController do
|
||||
|
||||
defp establish_sse_connection(conn, map_id, api_key, params) do
|
||||
# Parse event filter if provided
|
||||
event_filter =
|
||||
case Map.get(params, "events") do
|
||||
nil -> :all
|
||||
events -> EventFilter.parse(events)
|
||||
end
|
||||
event_filter = EventFilter.parse(Map.get(params, "events"))
|
||||
|
||||
# Parse format parameter
|
||||
event_format = Map.get(params, "format", "legacy")
|
||||
@@ -82,7 +78,7 @@ defmodule WandererAppWeb.Api.EventsController do
|
||||
send_event(
|
||||
conn,
|
||||
%{
|
||||
id: Ulid.generate(),
|
||||
id: Ecto.ULID.generate(),
|
||||
event: "connected",
|
||||
data: %{
|
||||
map_id: map_id,
|
||||
|
||||
@@ -15,30 +15,6 @@ defmodule WandererAppWeb.Endpoint do
|
||||
max_age: 24 * 60 * 60 * 180
|
||||
]
|
||||
|
||||
# @impl SiteEncrypt
|
||||
# def certification do
|
||||
# SiteEncrypt.configure(
|
||||
# client: :native,
|
||||
# mode: :auto,
|
||||
# days_to_renew: 30,
|
||||
# domains: ["dev.wanderer.deadly-w.space"],
|
||||
# emails: ["dmitriypopovsamara@gmail.com"],
|
||||
# db_folder: System.get_env("SITE_ENCRYPT_DB", Path.join("tmp", "site_encrypt_db")),
|
||||
# backup: Path.join(Path.join("tmp", "site_encrypt_db"), "site_encrypt_backup.tgz"),
|
||||
# directory_url:
|
||||
# case System.get_env("CERT_MODE", "local") do
|
||||
# "local" ->
|
||||
# {:internal, port: 4001}
|
||||
|
||||
# "staging" ->
|
||||
# "https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||
|
||||
# "production" ->
|
||||
# "https://acme-v02.api.letsencrypt.org/directory"
|
||||
# end
|
||||
# )
|
||||
# end
|
||||
|
||||
socket "/live", Phoenix.LiveView.Socket,
|
||||
websocket: [compress: true, connect_info: [session: @session_options]]
|
||||
|
||||
|
||||
4
mix.exs
4
mix.exs
@@ -3,7 +3,7 @@ defmodule WandererApp.MixProject do
|
||||
|
||||
@source_url "https://github.com/wanderer-industries/wanderer"
|
||||
|
||||
@version "1.76.1"
|
||||
@version "1.76.13"
|
||||
|
||||
def project do
|
||||
[
|
||||
@@ -105,7 +105,7 @@ defmodule WandererApp.MixProject do
|
||||
{:ash_postgres, "~> 2.4"},
|
||||
{:exsync, "~> 0.4", only: :dev},
|
||||
{:nimble_csv, "~> 1.2.0"},
|
||||
{:ulid, "~> 0.2.0"},
|
||||
{:ecto_ulid_next, "~> 1.0.2"},
|
||||
{:cachex, "~> 3.6"},
|
||||
{:live_select, "~> 1.5"},
|
||||
{:nebulex, "~> 2.6"},
|
||||
|
||||
2
mix.lock
2
mix.lock
@@ -35,6 +35,7 @@
|
||||
"earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"},
|
||||
"ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.12.0", "73cea17edfa54bde76ee8561b30d29ea08f630959685006d9c6e7d1e59113b7d", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dc9e4d206f274f3947e96142a8fdc5f69a2a6a9abb4649ef5c882323b6d512f0"},
|
||||
"ecto_ulid_next": {:hex, :ecto_ulid_next, "1.0.2", "8372f3c589c8fa50ea7b127dabe008528837b11781f65bfc72d96259d49b44c5", [:mix], [{:ecto, "~> 3.2", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "61c9c2c531f87ce7e2e9e57fc60d533fe97b3a62a43c21b632b0824f0773bcbe"},
|
||||
"erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"},
|
||||
"error_tracker": {:hex, :error_tracker, "0.2.2", "7635f5ed6016df10d8e63348375acb2ca411e2f6f9703ee90cc2d4262af5faec", [:mix], [{:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, ">= 0.0.0", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_ecto, "~> 4.6", [hex: :phoenix_ecto, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "b975978f64d27373d3486d7de477a699e735f8c0b1c74a7370ecb80e7ae97903"},
|
||||
"eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"},
|
||||
@@ -43,6 +44,7 @@
|
||||
"ex_check": {:hex, :ex_check, "0.14.0", "d6fbe0bcc51cf38fea276f5bc2af0c9ae0a2bb059f602f8de88709421dae4f0e", [:mix], [], "hexpm", "8a602e98c66e6a4be3a639321f1f545292042f290f91fa942a285888c6868af0"},
|
||||
"ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"},
|
||||
"ex_rated": {:hex, :ex_rated, "2.1.0", "d40e6fe35097b10222df2db7bb5dd801d57211bac65f29063de5f201c2a6aebc", [:mix], [{:ex2ms, "~> 1.5", [hex: :ex2ms, repo: "hexpm", optional: false]}], "hexpm", "936c155337253ed6474f06d941999dd3a9cf0fe767ec99a59f2d2989dc2cc13f"},
|
||||
"ex_ulid": {:hex, :ex_ulid, "0.1.0", "e6e717c57344f6e500d0190ccb4edc862b985a3680f15834af992ec065d4dcff", [:mix], [], "hexpm", "a2befd477aebc4639563de7e233e175cacf8a8f42c8f6778c88d60c13bf20860"},
|
||||
"excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"},
|
||||
"expo": {:hex, :expo, "0.5.2", "beba786aab8e3c5431813d7a44b828e7b922bfa431d6bfbada0904535342efe2", [:mix], [], "hexpm", "8c9bfa06ca017c9cb4020fabe980bc7fdb1aaec059fd004c2ab3bff03b1c599c"},
|
||||
"exsync": {:hex, :exsync, "0.4.1", "0a14fe4bfcb80a509d8a0856be3dd070fffe619b9ba90fec13c58b316c176594", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "cefb22aa805ec97ffc5b75a4e1dc54bcaf781e8b32564bf74abbe5803d1b5178"},
|
||||
|
||||
Reference in New Issue
Block a user