diff --git a/config/runtime.exs b/config/runtime.exs index 3fe2fbb9..7c09f0e7 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -405,7 +405,7 @@ config :wanderer_app, :license_manager, config :wanderer_app, :sse, enabled: config_dir - |> get_var_from_path_or_env("WANDERER_SSE_ENABLED", "true") + |> get_var_from_path_or_env("WANDERER_SSE_ENABLED", "false") |> String.to_existing_atom(), max_connections_total: config_dir |> get_int_from_path_or_env("WANDERER_SSE_MAX_CONNECTIONS", 1000), @@ -420,6 +420,6 @@ config :wanderer_app, :sse, config :wanderer_app, :external_events, webhooks_enabled: config_dir - |> get_var_from_path_or_env("WANDERER_WEBHOOKS_ENABLED", "true") + |> get_var_from_path_or_env("WANDERER_WEBHOOKS_ENABLED", "false") |> String.to_existing_atom(), webhook_timeout_ms: config_dir |> get_int_from_path_or_env("WANDERER_WEBHOOK_TIMEOUT_MS", 15000) diff --git a/lib/wanderer_app/api/changes/inject_map_from_actor.ex b/lib/wanderer_app/api/changes/inject_map_from_actor.ex index 29d2d9cb..c8edf9ed 100644 --- a/lib/wanderer_app/api/changes/inject_map_from_actor.ex +++ b/lib/wanderer_app/api/changes/inject_map_from_actor.ex @@ -16,7 +16,8 @@ defmodule WandererApp.Api.Changes.InjectMapFromActor do %{id: map_id} -> Ash.Changeset.force_change_attribute(changeset, :map_id, map_id) - nil -> + _other -> + # nil or unexpected return shape - check for direct map_id case Ash.Changeset.get_attribute(changeset, :map_id) do nil -> Ash.Changeset.add_error(changeset, diff --git a/lib/wanderer_app/env.ex b/lib/wanderer_app/env.ex index 5f89b773..3a8a611c 100644 --- a/lib/wanderer_app/env.ex +++ b/lib/wanderer_app/env.ex @@ -17,7 +17,6 @@ defmodule WandererApp.Env do def invites(), do: get_key(:invites, false) def map_subscriptions_enabled?(), do: get_key(:map_subscriptions_enabled, false) - def websocket_events_enabled?(), do: get_key(:websocket_events_enabled, false) def public_api_disabled?(), do: get_key(:public_api_disabled, false) @decorate cacheable( diff --git a/lib/wanderer_app/map/operations/signatures.ex b/lib/wanderer_app/map/operations/signatures.ex index 570b85f6..7419dd05 100644 --- a/lib/wanderer_app/map/operations/signatures.ex +++ b/lib/wanderer_app/map/operations/signatures.ex @@ -9,7 +9,7 @@ defmodule WandererApp.Map.Operations.Signatures do alias WandererApp.Map.Server @spec validate_character_eve_id(map() | nil, String.t()) :: - {:ok, String.t()} | {:error, :invalid_character} + {:ok, String.t()} | {:error, :invalid_character} | {:error, :unexpected_error} defp validate_character_eve_id(params, fallback_char_id) when is_map(params) do case Map.get(params, "character_eve_id") do nil -> @@ -121,6 +121,10 @@ defmodule WandererApp.Map.Operations.Signatures do Logger.error("[create_signature] Invalid character_eve_id provided") {:error, :invalid_character} + {:error, :unexpected_error} -> + Logger.error("[create_signature] Unexpected error during character validation") + {:error, :unexpected_error} + _ -> Logger.error( "[create_signature] System not found for solar_system_id: #{solar_system_id}" @@ -191,9 +195,13 @@ defmodule WandererApp.Map.Operations.Signatures do Logger.error("[update_signature] Invalid character_eve_id provided") {:error, :invalid_character} - err -> - Logger.error("[update_signature] Unexpected error: #{inspect(err)}") + {:error, :unexpected_error} -> + Logger.error("[update_signature] Unexpected error during character validation") {:error, :unexpected_error} + + err -> + Logger.error("[update_signature] Signature or system not found: #{inspect(err)}") + {:error, :not_found} end end diff --git a/lib/wanderer_app_web/api_spec_v1.ex b/lib/wanderer_app_web/api_spec_v1.ex index 922260e5..411c9a8c 100644 --- a/lib/wanderer_app_web/api_spec_v1.ex +++ b/lib/wanderer_app_web/api_spec_v1.ex @@ -12,11 +12,16 @@ defmodule WandererAppWeb.ApiSpecV1 do # Get the base spec from the original base_spec = WandererAppWeb.ApiSpec.spec() - # Get v1 spec + # Get v1 spec v1_spec = WandererAppWeb.OpenApiV1Spec.spec() + # Tag legacy paths and v1 paths appropriately + tagged_legacy_paths = tag_paths(base_spec.paths || %{}, "Legacy API") + # v1 paths already have tags from AshJsonApi, keep them as-is + v1_paths = v1_spec.paths || %{} + # Merge the specs - merged_paths = Map.merge(base_spec.paths || %{}, v1_spec.paths || %{}) + merged_paths = Map.merge(tagged_legacy_paths, v1_paths) # Merge components merged_components = %Components{ @@ -84,11 +89,53 @@ defmodule WandererAppWeb.ApiSpecV1 do # Get tags from v1 spec if available spec_tags = Map.get(v1_spec, :tags, []) - # Add custom v1 tags - v1_label_tags = [ - %{name: "v1 JSON:API", description: "JSON:API compliant endpoints with advanced querying"} - ] - - base_tags ++ v1_label_tags ++ spec_tags + base_tags ++ spec_tags end + + # Tag all operations in paths with the given tag + defp tag_paths(paths, tag) when is_map(paths) do + Map.new(paths, fn {path, path_item} -> + {path, tag_path_item(path_item, tag)} + end) + end + + # Handle OpenApiSpex.PathItem structs + defp tag_path_item(%OpenApiSpex.PathItem{} = path_item, tag) do + path_item + |> maybe_tag_operation(:get, tag) + |> maybe_tag_operation(:put, tag) + |> maybe_tag_operation(:post, tag) + |> maybe_tag_operation(:delete, tag) + |> maybe_tag_operation(:patch, tag) + |> maybe_tag_operation(:options, tag) + |> maybe_tag_operation(:head, tag) + end + + # Handle plain maps (from AshJsonApi) + defp tag_path_item(path_item, tag) when is_map(path_item) do + Map.new(path_item, fn {method, operation} -> + {method, add_tag_to_operation(operation, tag)} + end) + end + + defp tag_path_item(path_item, _tag), do: path_item + + defp maybe_tag_operation(path_item, method, tag) do + case Map.get(path_item, method) do + nil -> path_item + operation -> Map.put(path_item, method, add_tag_to_operation(operation, tag)) + end + end + + defp add_tag_to_operation(%OpenApiSpex.Operation{} = operation, tag) do + %{operation | tags: [tag | List.wrap(operation.tags)]} + end + + defp add_tag_to_operation(%{} = operation, tag) do + Map.update(operation, :tags, [tag], fn existing_tags -> + [tag | List.wrap(existing_tags)] + end) + end + + defp add_tag_to_operation(operation, _tag), do: operation end diff --git a/lib/wanderer_app_web/controllers/plugs/check_webhooks_disabled.ex b/lib/wanderer_app_web/controllers/plugs/check_webhooks_disabled.ex new file mode 100644 index 00000000..a0987abe --- /dev/null +++ b/lib/wanderer_app_web/controllers/plugs/check_webhooks_disabled.ex @@ -0,0 +1,21 @@ +defmodule WandererAppWeb.Plugs.CheckWebhooksDisabled do + @moduledoc """ + Plug to check if webhooks are enabled. + + This plug blocks access to webhook management endpoints when webhooks are disabled. + Enable webhooks by setting WANDERER_WEBHOOKS_ENABLED=true in your environment. + """ + import Plug.Conn + + def init(opts), do: opts + + def call(conn, _opts) do + if not WandererApp.Env.webhooks_enabled?() do + conn + |> send_resp(403, "Webhooks are disabled. Set WANDERER_WEBHOOKS_ENABLED=true to enable.") + |> halt() + else + conn + end + end +end diff --git a/lib/wanderer_app_web/controllers/plugs/check_websocket_disabled.ex b/lib/wanderer_app_web/controllers/plugs/check_websocket_disabled.ex deleted file mode 100644 index 16c24360..00000000 --- a/lib/wanderer_app_web/controllers/plugs/check_websocket_disabled.ex +++ /dev/null @@ -1,15 +0,0 @@ -defmodule WandererAppWeb.Plugs.CheckWebsocketDisabled do - import Plug.Conn - - def init(opts), do: opts - - def call(conn, _opts) do - if not WandererApp.Env.websocket_events_enabled?() do - conn - |> send_resp(403, "WebSocket events are disabled") - |> halt() - else - conn - end - end -end diff --git a/lib/wanderer_app_web/router.ex b/lib/wanderer_app_web/router.ex index 9932f216..e7b41d6e 100644 --- a/lib/wanderer_app_web/router.ex +++ b/lib/wanderer_app_web/router.ex @@ -201,8 +201,8 @@ defmodule WandererAppWeb.Router do plug WandererAppWeb.Plugs.CheckCharacterApiDisabled end - pipeline :api_websocket_events do - plug WandererAppWeb.Plugs.CheckWebsocketDisabled + pipeline :api_webhooks do + plug WandererAppWeb.Plugs.CheckWebhooksDisabled end pipeline :api_acl do @@ -302,9 +302,9 @@ defmodule WandererAppWeb.Router do get "/tracked-characters", MapAPIController, :show_tracked_characters end - # WebSocket events and webhook management endpoints (disabled by default) + # Webhook management endpoints (requires WANDERER_WEBHOOKS_ENABLED=true) scope "/api/maps/:map_identifier", WandererAppWeb do - pipe_through [:api, :api_map, :api_websocket_events] + pipe_through [:api, :api_map, :api_webhooks] get "/events", MapEventsAPIController, :list_events diff --git a/priv/posts/2025/06-21-webhooks.md b/priv/posts/2025/06-21-webhooks.md index 468efe38..2fdb1c4e 100644 --- a/priv/posts/2025/06-21-webhooks.md +++ b/priv/posts/2025/06-21-webhooks.md @@ -42,6 +42,24 @@ In the dynamic world of EVE Online wormhole mapping, every second counts. When a - Your map API token (found in map settings) - Basic programming knowledge for integration +### Server Configuration (Community Edition) + +If you're running Wanderer Community Edition (CE), you need to enable the required features via environment variables: + +**For SSE (Server-Sent Events):** +```bash +WANDERER_SSE_ENABLED=true +``` + +**For Webhooks:** +```bash +WANDERER_WEBHOOKS_ENABLED=true +``` + +Add these to your `.env` file or Docker environment configuration and restart Wanderer. Without these settings, you'll receive a 403 error when trying to access SSE streams or webhook management endpoints. + +*Note: The public Wanderer instance at wanderer.ltd has these features enabled by default.* + ### Authentication Both SSE and webhook APIs use your existing map API token for authentication. This token should be kept secure and never exposed in client-side code. diff --git a/priv/posts/2025/07-15-api-modernization.md b/priv/posts/2025/07-15-api-modernization.md index 7ae5d130..21caee81 100644 --- a/priv/posts/2025/07-15-api-modernization.md +++ b/priv/posts/2025/07-15-api-modernization.md @@ -81,6 +81,24 @@ You can find or generate your map's API key in the map settings within the Wande **Session Authentication:** Web clients can also use session-based authentication for interactive use, maintaining compatibility with existing browser-based integrations. +### Server Configuration (Community Edition) + +If you're running Wanderer Community Edition (CE), ensure the following environment variables are configured: + +**Required for API access:** +```bash +WANDERER_PUBLIC_API_DISABLED=false # Enable public API (default: false) +``` + +**Optional features:** +```bash +WANDERER_SSE_ENABLED=true # Enable Server-Sent Events (default: false) +WANDERER_WEBHOOKS_ENABLED=true # Enable webhook management (default: false) +WANDERER_CHARACTER_API_DISABLED=false # Enable character API (default: true) +``` + +Add these to your `.env` file or Docker environment configuration and restart Wanderer. + ## JSON:API Features ### Resource Relationships @@ -143,7 +161,7 @@ GET /api/v1/user_activities?include=character&sort=-inserted_at&page[limit]=15&p The API v1 provides access to over 25 resources through the Ash Framework. Here are the primary resources: ### Core Resources -- **Maps** (`/api/v1/maps`) - Map management with full CRUD operations +- **Maps** (`/api/v1/maps/:slug`) - Map management with create, read, update, and delete operations (accessed by slug with map-specific API key; no listing endpoint) - **Access Lists** (`/api/v1/access_lists`) - ACL management and permissions with full CRUD operations - **Access List Members** (`/api/v1/access_list_members`) - ACL member management with full CRUD operations - **Map Access Lists** (`/api/v1/map_access_lists`) - Map-ACL associations with full CRUD operations diff --git a/priv/repo/migrations/20251118202140_add_public_api_key_unique_index.exs b/priv/repo/migrations/20251118202140_add_public_api_key_unique_index.exs index 3a4baf08..5d20a4f7 100644 --- a/priv/repo/migrations/20251118202140_add_public_api_key_unique_index.exs +++ b/priv/repo/migrations/20251118202140_add_public_api_key_unique_index.exs @@ -3,20 +3,40 @@ defmodule WandererApp.Repo.Migrations.AddPublicApiKeyUniqueIndex do Adds a unique index on the public_api_key column of maps_v1. This migration: - 1. Creates a unique index on public_api_key where the value is not null - 2. Allows multiple NULL values (maps without API keys) - 3. Ensures all non-NULL API keys are unique + 1. Creates a backup table (maps_v1_api_key_backup) for data safety + 2. Backs up and clears duplicate API keys (keeping the oldest by inserted_at) + 3. Creates a unique index on public_api_key where the value is not null + 4. Allows multiple NULL values (maps without API keys) + 5. Ensures all non-NULL API keys are unique The partial index (WHERE public_api_key IS NOT NULL) is used because: - Most maps won't have an API key set - We only care about uniqueness for maps that do have one - PostgreSQL's unique constraints on nullable columns already allow multiple NULLs, but a partial index is more explicit and efficient + + ## Data Recovery + + If you need to restore cleared API keys, query the backup table: + + SELECT map_id, old_public_api_key, backed_up_at + FROM maps_v1_api_key_backup + WHERE reason = 'duplicate_api_key_cleared_for_unique_index'; + + To restore a specific map's API key: + + UPDATE maps_v1 SET public_api_key = '' + WHERE id = ''; + + Note: Restoring will cause uniqueness conflicts if duplicates still exist. """ use Ecto.Migration def up do - # First, check for any duplicate non-null API keys and handle them + # Create backup table before any destructive changes + create_backup_table() + + # Check for any duplicate non-null API keys and handle them (with backup) check_and_fix_duplicates() # Create the unique index @@ -31,6 +51,20 @@ defmodule WandererApp.Repo.Migrations.AddPublicApiKeyUniqueIndex do IO.puts("Created unique index on maps_v1.public_api_key") end + defp create_backup_table do + repo().query!(""" + CREATE TABLE IF NOT EXISTS maps_v1_api_key_backup ( + id UUID PRIMARY KEY, + map_id UUID NOT NULL, + old_public_api_key TEXT NOT NULL, + reason TEXT NOT NULL, + backed_up_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + """, []) + + IO.puts("Created backup table maps_v1_api_key_backup") + end + def down do drop_if_exists( index(:maps_v1, [:public_api_key], @@ -39,6 +73,10 @@ defmodule WandererApp.Repo.Migrations.AddPublicApiKeyUniqueIndex do ) IO.puts("Dropped unique index on maps_v1.public_api_key") + + # Drop backup table + repo().query!("DROP TABLE IF EXISTS maps_v1_api_key_backup", []) + IO.puts("Dropped backup table maps_v1_api_key_backup") end defp check_and_fix_duplicates do @@ -76,20 +114,28 @@ defmodule WandererApp.Repo.Migrations.AddPublicApiKeyUniqueIndex do [_keep | clear_ids] = Enum.map(id_rows, fn [id] -> id end) Enum.each(clear_ids, fn id -> + # Backup the API key before clearing + backup_query = """ + INSERT INTO maps_v1_api_key_backup (id, map_id, old_public_api_key, reason) + VALUES (gen_random_uuid(), $1::uuid, $2, 'duplicate_api_key_cleared_for_unique_index') + """ + repo().query!(backup_query, [id, api_key]) + + # Clear the duplicate clear_query = "UPDATE maps_v1 SET public_api_key = NULL WHERE id::text = $1" repo().query!(clear_query, [id]) - IO.puts(" Cleared API key for map #{id}") + IO.puts(" Backed up and cleared API key for map #{id}") end) {:error, error} -> - IO.puts("Error getting IDs: #{inspect(error)}") + raise "Failed to get duplicate IDs for key: #{inspect(error)}" end end) IO.puts("Duplicate API keys cleared") {:error, error} -> - IO.puts("Error checking for duplicates: #{inspect(error)}") + raise "Failed to check for duplicate keys: #{inspect(error)}" end end end