feat: add api for acl management (#171)

This commit is contained in:
guarzo
2025-02-15 03:16:42 -05:00
committed by GitHub
parent dbcad892a9
commit 8c5366fd9b
19 changed files with 1164 additions and 106 deletions

View File

@@ -1,7 +1,12 @@
{
"name": "wanderer-dev",
"dockerComposeFile": ["./docker-compose.yml"],
"extensions": ["jakebecker.elixir-ls"],
"extensions": [
"jakebecker.elixir-ls",
"JakeBecker.elixir-ls",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
],
"service": "wanderer",
"workspaceFolder": "/app",
"shutdownAction": "stopCompose",

View File

@@ -7,4 +7,5 @@ export EVE_CLIENT_WITH_WALLET_SECRET="<EVE_CLIENT_WITH_WALLET_SECRET>"
export GIT_SHA="1111"
export WANDERER_INVITES="false"
export WANDERER_PUBLIC_API_DISABLED="false"
export WANDERER_CHARACTER_API_DISABLED="false"
export WANDERER_ZKILL_PRELOAD_DISABLED="false"

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -53,6 +53,11 @@ public_api_disabled =
|> get_var_from_path_or_env("WANDERER_PUBLIC_API_DISABLED", "false")
|> String.to_existing_atom()
character_api_disabled =
config_dir
|> get_var_from_path_or_env("WANDERER_CHARACTER_API_DISABLED", "false")
|> String.to_existing_atom()
zkill_preload_disabled =
config_dir
|> get_var_from_path_or_env("WANDERER_ZKILL_PRELOAD_DISABLED", "false")
@@ -123,6 +128,7 @@ config :wanderer_app,
corp_id: System.get_env("WANDERER_CORP_ID", "-1") |> String.to_integer(),
corp_wallet: System.get_env("WANDERER_CORP_WALLET", ""),
public_api_disabled: public_api_disabled,
character_api_disabled: character_api_disabled,
zkill_preload_disabled: zkill_preload_disabled,
map_subscriptions_enabled: map_subscriptions_enabled,
map_connection_auto_expire_hours: map_connection_auto_expire_hours,

View File

@@ -12,7 +12,6 @@ defmodule WandererApp.Api.AccessList do
code_interface do
define(:create, action: :create)
define(:available, action: :available)
define(:new, action: :new)
define(:read, action: :read)
@@ -39,7 +38,8 @@ defmodule WandererApp.Api.AccessList do
end
create :new do
accept [:name, :description, :owner_id]
# Added :api_key to the accepted attributes
accept [:name, :description, :owner_id, :api_key]
primary?(true)
argument :owner_id, :uuid, allow_nil?: false
@@ -48,7 +48,7 @@ defmodule WandererApp.Api.AccessList do
end
update :update do
accept [:name, :description, :owner_id]
accept [:name, :description, :owner_id, :api_key]
primary?(true)
end
@@ -68,6 +68,10 @@ defmodule WandererApp.Api.AccessList do
allow_nil? true
end
attribute :api_key, :string do
allow_nil? true
end
create_timestamp(:inserted_at)
update_timestamp(:updated_at)
end

View File

@@ -11,6 +11,7 @@ defmodule WandererApp.Env do
def invites, do: get_key(:invites, false)
def map_subscriptions_enabled?, do: get_key(:map_subscriptions_enabled, false)
def public_api_disabled?, do: get_key(:public_api_disabled, false)
def character_api_disabled?, do: get_key(:character_api_disabled, false)
def zkill_preload_disabled?, do: get_key(:zkill_preload_disabled, false)
def wallet_tracking_enabled?, do: get_key(:wallet_tracking_enabled, false)
def admins, do: get_key(:admins, [])

View File

@@ -0,0 +1,181 @@
defmodule WandererAppWeb.MapAccessListAPIController do
@moduledoc """
API endpoints for managing Access Lists.
Endpoints:
- GET /api/map/acls?map_id=... or ?slug=... (list ACLs)
- POST /api/map/acls (create ACL)
- GET /api/acls/:id (show ACL)
- PUT /api/acls/:id (update ACL)
ACL members are managed via a separate controller.
"""
use WandererAppWeb, :controller
alias WandererApp.Api.{AccessList, Character}
# Do not alias Map—to avoid conflicts—use the full module name: WandererApp.Map.
alias WandererAppWeb.UtilAPIController, as: Util
import Ash.Query
# List ACLs for a given map (returns reduced info: no api_key, no members, and includes owner_eve_id)
def index(conn, params) do
case Util.fetch_map_id(params) do
{:ok, map_identifier} ->
with {:ok, map} <- get_map(map_identifier) do
acls = map.acls || []
json(conn, %{data: Enum.map(acls, &acl_to_list_json/1)})
else
{:error, :map_not_found} ->
conn |> put_status(:not_found) |> json(%{error: "Map not found"})
{:error, error} ->
conn |> put_status(:internal_server_error) |> json(%{error: inspect(error)})
end
{:error, msg} ->
conn |> put_status(:bad_request) |> json(%{error: msg})
end
end
# Create a new ACL for a map
def create(conn, params) do
with {:ok, map_identifier} <- Util.fetch_map_id(params),
{:ok, map} <- get_map(map_identifier),
%{"acl" => acl_params} <- params,
owner_eve_id when is_binary(owner_eve_id) <- Map.get(acl_params, "owner_eve_id"),
{:ok, character} <- find_character_by_eve_id(owner_eve_id),
{:ok, new_api_key} <- {:ok, UUID.uuid4()},
{:ok, new_params} <- {:ok,
acl_params
|> Map.delete("owner_eve_id")
|> Map.put("owner_id", character.id)
|> Map.put("api_key", new_api_key)
},
{:ok, new_acl} <- AccessList.new(new_params),
{:ok, _} <- {:ok, associate_acl_with_map(map, new_acl)}
do
json(conn, %{data: acl_to_json(new_acl)})
else
error ->
conn |> put_status(:bad_request) |> json(%{error: inspect(error)})
end
end
# Show a specific ACL (with members)
def show(conn, %{"id" => id}) do
query = AccessList |> Ash.Query.new() |> filter(id == ^id)
case WandererApp.Api.read(query) do
{:ok, [acl]} ->
case Ash.load(acl, :members) do
{:ok, loaded_acl} -> json(conn, %{data: acl_to_json(loaded_acl)})
{:error, error} -> conn |> put_status(:internal_server_error) |> json(%{error: "Failed to load ACL members: #{inspect(error)}"})
end
{:ok, []} ->
conn |> put_status(:not_found) |> json(%{error: "ACL not found"})
{:error, error} ->
conn |> put_status(:internal_server_error) |> json(%{error: "Error reading ACL: #{inspect(error)}"})
end
end
# Update an ACL (if needed)
def update(conn, %{"id" => id, "acl" => acl_params}) do
with {:ok, acl} <- AccessList.by_id(id),
{:ok, updated_acl} <- AccessList.update(acl, acl_params),
{:ok, updated_acl} <- Ash.load(updated_acl, :members) do
json(conn, %{data: acl_to_json(updated_acl)})
else
{:error, error} ->
conn |> put_status(:bad_request) |> json(%{error: "Failed to update ACL: #{inspect(error)}"})
end
end
# Helper to get the map (using your module WandererApp.Map)
defp get_map(map_identifier) do
# Assuming Util.fetch_map_id returns a map id.
case WandererApp.Map.get_map(map_identifier) do
{:ok, map} -> {:ok, map}
other -> other
end
end
# Helper to convert an ACL to full JSON (for detail views)
defp acl_to_json(acl) do
members =
case acl.members do
%Ash.NotLoaded{} -> []
list when is_list(list) -> Enum.map(list, &member_to_json/1)
_ -> []
end
%{
id: acl.id,
name: acl.name,
description: acl.description,
owner_id: acl.owner_id,
api_key: acl.api_key,
inserted_at: acl.inserted_at,
updated_at: acl.updated_at,
members: members
}
end
defp acl_to_list_json(acl) do
full_acl =
case AccessList.by_id(acl.id) do
{:ok, loaded_acl} -> loaded_acl
_ -> acl
end
owner_eve_id =
case find_character_by_id(full_acl.owner_id) do
{:ok, character} -> character.eve_id
_ -> nil
end
%{
id: full_acl.id,
name: full_acl.name,
description: full_acl.description,
owner_eve_id: owner_eve_id,
inserted_at: full_acl.inserted_at,
updated_at: full_acl.updated_at
}
end
defp member_to_json(member) do
%{
id: member.id,
name: member.name,
role: member.role,
eve_character_id: member.eve_character_id,
inserted_at: member.inserted_at,
updated_at: member.updated_at
}
end
# Helper to find a character by external EVE id (used in create action)
defp find_character_by_eve_id(eve_id) do
query = Character |> Ash.Query.new() |> filter(eve_id == ^eve_id)
case WandererApp.Api.read(query) do
{:ok, [character]} -> {:ok, character}
{:ok, []} -> {:error, "owner_eve_id does not match any existing character"}
other -> other
end
end
# Helper to find a character by internal id (used in acl_to_list_json)
defp find_character_by_id(id) do
query = Character |> Ash.Query.new() |> filter(id == ^id)
case WandererApp.Api.read(query) do
{:ok, [character]} -> {:ok, character}
{:ok, []} -> {:error, "Character not found"}
other -> other
end
end
# Associate the new ACL with the map by updating the map's acls list.
defp associate_acl_with_map(map, new_acl) do
current_acls = map.acls || []
updated_acls = current_acls ++ [new_acl]
case WandererApp.Map.update_map(map.map_id, %{acls: updated_acls}) do
_ -> :ok
end
end
end

View File

@@ -0,0 +1,157 @@
defmodule WandererAppWeb.AccessListMemberAPIController do
@moduledoc """
Handles creation, role updates, and deletion of individual ACL members.
"""
use WandererAppWeb, :controller
alias WandererApp.Api.{AccessListMember, Character}
import Ash.Query
@doc """
POST /api/acls/:acl_id/members
Creates a new member for the given ACL.
Request Body example:
{
"member": {
"eve_character_id": "CHARACTER_EXTERNAL_EVE_ID",
"role": "viewer" // optional; defaults to "viewer" if not provided
}
}
Behavior:
The controller looks up the character by filtering on its external EVE ID (eve_id),
injects the character's name into the membership, and creates the membership record.
"""
def create(conn, %{"acl_id" => acl_id, "member" => member_params}) do
with eve_id when not is_nil(eve_id) <- Map.get(member_params, "eve_character_id"),
# Build a query to find the character by its external EVE id (eve_id)
query = Character |> Ash.Query.new() |> filter(eve_id == ^eve_id),
{:ok, characters} <- WandererApp.Api.read(query),
[character] <- characters do
# Inject the looked-up name into the parameters.
member_params = Map.put(member_params, "name", character.name)
# Merge in the ACL id so that Ash knows which ACL the member belongs to.
merged_params = Map.put(member_params, "access_list_id", acl_id)
case AccessListMember.create(merged_params) do
{:ok, new_member} ->
json(conn, %{data: member_to_json(new_member)})
{:error, error} ->
conn
|> put_status(:bad_request)
|> json(%{error: "Failed to create member: #{inspect(error)}"})
end
else
nil ->
conn
|> put_status(:bad_request)
|> json(%{error: "Missing eve_character_id in member payload"})
{:ok, []} ->
conn
|> put_status(:not_found)
|> json(%{error: "Character not found"})
{:error, error} ->
conn
|> put_status(:bad_request)
|> json(%{error: "Character lookup failed: #{inspect(error)}"})
end
end
@doc """
PUT /api/acls/:acl_id/members/:member_id
Updates a single ACL members role based on the external EVE ID provided in the URL.
Request Body example:
{
"member": {
"role": "admin"
}
}
"""
def update_role(conn, %{"acl_id" => acl_id, "member_id" => eve_id, "member" => member_params}) do
membership_query =
AccessListMember
|> Ash.Query.new()
|> filter(eve_character_id == ^eve_id)
|> filter(access_list_id == ^acl_id)
case WandererApp.Api.read(membership_query) do
{:ok, [membership]} ->
case AccessListMember.update_role(membership, member_params) do
{:ok, updated_membership} ->
json(conn, %{data: member_to_json(updated_membership)})
{:error, error} ->
conn
|> put_status(:bad_request)
|> json(%{error: inspect(error)})
end
{:ok, []} ->
conn
|> put_status(:not_found)
|> json(%{error: "Membership not found for given ACL and eve_character_id"})
{:error, error} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: inspect(error)})
end
end
@doc """
DELETE /api/acls/:acl_id/members/:member_id
Deletes a member from an ACL based on the external EVE ID provided in the URL.
"""
def delete(conn, %{"acl_id" => acl_id, "member_id" => eve_id}) do
membership_query =
AccessListMember
|> Ash.Query.new()
|> filter(eve_character_id == ^eve_id)
|> filter(access_list_id == ^acl_id)
case WandererApp.Api.read(membership_query) do
{:ok, [membership]} ->
case AccessListMember.destroy(membership) do
:ok ->
json(conn, %{ok: true})
{:error, error} ->
conn
|> put_status(:bad_request)
|> json(%{error: inspect(error)})
end
{:ok, []} ->
conn
|> put_status(:not_found)
|> json(%{error: "Membership not found for given ACL and eve_character_id"})
{:error, error} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: inspect(error)})
end
end
# ---------------------------------------------------------------------------
# Private Helpers
# ---------------------------------------------------------------------------
defp member_to_json(member) do
%{
id: member.id,
name: member.name,
role: member.role,
eve_character_id: member.eve_character_id,
inserted_at: member.inserted_at,
updated_at: member.updated_at
}
end
end

View File

@@ -0,0 +1,39 @@
defmodule WandererAppWeb.CharactersAPIController do
@moduledoc """
Exposes an endpoint for listing ALL characters in the database
Endpoint:
GET /api/characters
"""
use WandererAppWeb, :controller
alias WandererApp.Api.Character
@doc """
GET /api/characters
Lists ALL characters in the database
Returns an array of objects, each with `id`, `eve_id`, `name`, etc.
"""
def index(conn, _params) do
case WandererApp.Api.read(Character) do
{:ok, characters} ->
result =
characters
|> Enum.map(&%{
id: &1.id,
eve_id: &1.eve_id,
name: &1.name,
corporation_name: &1.corporation_name,
alliance_name: &1.alliance_name
})
json(conn, %{data: result})
{:error, error} ->
conn
|> put_status(:internal_server_error)
|> json(%{error: inspect(error)})
end
end
end

View File

@@ -0,0 +1,55 @@
defmodule WandererAppWeb.Plugs.CheckAclApiKey do
@moduledoc """
A plug that checks the "Authorization: Bearer <token>" header
against the ACLs stored api_key.
"""
import Plug.Conn
alias WandererApp.Repo
alias WandererApp.Api.AccessList
def init(opts), do: opts
def call(conn, _opts) do
header = get_req_header(conn, "authorization") |> List.first()
case header do
"Bearer " <> incoming_token ->
acl_id = conn.params["id"] || conn.params["acl_id"]
if acl_id do
case Repo.get(AccessList, acl_id) do
nil ->
conn
|> send_resp(404, "ACL not found")
|> halt()
acl ->
cond do
is_nil(acl.api_key) ->
conn
|> send_resp(401, "Unauthorized (no API key set for ACL)")
|> halt()
acl.api_key == incoming_token ->
conn
true ->
conn
|> send_resp(401, "Unauthorized (invalid API key for ACL)")
|> halt()
end
end
else
conn
|> send_resp(400, "ACL ID not provided")
|> halt()
end
_ ->
conn
|> send_resp(401, "Missing or invalid 'Bearer' token")
|> halt()
end
end
end

View File

@@ -0,0 +1,15 @@
defmodule WandererAppWeb.Plugs.CheckCharacterApiDisabled do
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
if WandererApp.Env.character_api_disabled?() do
conn
|> send_resp(403, "Character API is disabled")
|> halt()
else
conn
end
end
end

View File

@@ -5,6 +5,7 @@ defmodule WandererAppWeb.Plugs.CheckMapApiKey do
"""
import Plug.Conn
alias WandererAppWeb.UtilAPIController, as: Util
def init(opts), do: opts
@@ -37,10 +38,7 @@ defmodule WandererAppWeb.Plugs.CheckMapApiKey do
end
defp fetch_map(query_params) do
case fetch_map_id(query_params) do
{:ok, {:map, map}} ->
{:ok, map}
case Util.fetch_map_id(query_params) do
{:ok, map_id} ->
WandererApp.Api.Map.by_id(map_id)
@@ -48,20 +46,4 @@ defmodule WandererAppWeb.Plugs.CheckMapApiKey do
error
end
end
defp fetch_map_id(%{"map_id" => mid}) when is_binary(mid) and mid != "" do
{:ok, mid}
end
defp fetch_map_id(%{"slug" => slug}) when is_binary(slug) and slug != "" do
case WandererApp.Api.Map.get_map_by_slug(slug) do
{:ok, map} ->
{:ok, {:map, map}}
{:error, _reason} ->
{:error, "No map found for slug=#{slug}"}
end
end
defp fetch_map_id(_), do: {:error, "Must provide either ?map_id=UUID or ?slug=SLUG"}
end

View File

@@ -314,6 +314,24 @@ defmodule WandererAppWeb.AccessListsLive do
end
end
def handle_event("generate-api-key", _params, socket) do
new_api_key = UUID.uuid4()
new_params = Map.put(socket.assigns.form.params || %{}, "api_key", new_api_key)
form = AshPhoenix.Form.validate(socket.assigns.form, new_params)
{:noreply, assign(socket, form: form)}
end
@impl true
def handle_event("noop", _, socket) do
{:noreply, socket}
end
@impl true
def handle_event(event, body, socket) do
Logger.warning(fn -> "unhandled event: #{event} #{inspect(body)}" end)
{:noreply, socket}
end
@impl true
def handle_info(
{"update_role", %{member_id: member_id, role: role}},
@@ -328,17 +346,6 @@ defmodule WandererAppWeb.AccessListsLive do
{:noreply, socket |> maybe_update_role(member, role_atom, access_list)}
end
@impl true
def handle_event("noop", _, socket) do
{:noreply, socket}
end
@impl true
def handle_event(event, body, socket) do
Logger.warning(fn -> "unhandled event: #{event} #{inspect(body)}" end)
{:noreply, socket}
end
@impl true
def handle_info({:search, text}, socket) do
active_character_id =

View File

@@ -142,6 +142,50 @@
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>
<div class="grid grid-cols-12 gap-2">
<div class="col-span-7">
<.input
type="text"
field={f[:api_key]}
placeholder="No API Key yet"
readonly
class="w-full"
/>
</div>
<div class="col-span-3">
<.button
type="button"
phx-click="generate-api-key"
class="p-button p-component p-button-primary w-full"
style="min-width: 0;"
>
<span class="p-button-label">Generate</span>
</.button>
</div>
<div class="col-span-2">
<.button
type="button"
phx-hook="CopyToClipboard"
id="copy-acl-api-key"
data-url={f[:api_key].value}
disabled={is_nil(f[:api_key].value) or f[:api_key].value == ""}
class={"p-button p-component w-full " <> if(is_nil(f[:api_key].value) or f[:api_key].value == "", do: "p-disabled", else: "")}
>
<span class="p-button-label">Copy</span>
</.button>
</div>
</div>
</div>
<hr class="my-4 border-gray-600" />
<div class="modal-action">
<.button class="mt-2" type="submit" phx-disable-with="Saving...">
<%= (@live_action == :create && "Create") || "Save" %>

View File

@@ -125,6 +125,7 @@
<% end %>
</main>
</div>
<.modal
:if={@is_connected? && @live_action in [:create, :edit]}
title={"#{(@live_action == :create && "Create") || "Edit"} Map"}
@@ -256,9 +257,7 @@
:if={@map_subscriptions_enabled?}
class={[
"p-unselectable-text",
classes(
"p-tabview-selected p-highlight": @active_settings_tab == "subscription"
)
classes("p-tabview-selected p-highlight": @active_settings_tab == "subscription")
]}
role="presentation"
data-pc-name=""
@@ -310,9 +309,7 @@
:if={not WandererApp.Env.public_api_disabled?()}
class={[
"p-unselectable-text",
classes(
"p-tabview-selected p-highlight": @active_settings_tab == "public_api"
)
classes("p-tabview-selected p-highlight": @active_settings_tab == "public_api")
]}
role="presentation"
data-pc-name=""
@@ -414,10 +411,7 @@
</div>
<div
:if={
@active_settings_tab == "public_api" and
not WandererApp.Env.public_api_disabled?()
}
:if={@active_settings_tab == "public_api" and not WandererApp.Env.public_api_disabled?()}
class="p-6"
>
<h2 class="text-lg font-semibold mb-4">Public API</h2>
@@ -439,29 +433,28 @@
/>
</div>
<div class="flex items-center gap-2">
<.button class="btn btn-primary rounded-md" phx-click="generate-map-api-key">
Generate
<.button
type="button"
phx-click="generate-map-api-key"
class="p-button p-component p-button-primary"
style="min-width: 120px;"
>
<span class="p-button-label">Generate</span>
</.button>
<.button
type="button"
phx-hook="CopyToClipboard"
id="copy-map-api-key"
data-url={@public_api_key}
disabled={is_nil(@public_api_key)}
class={
if is_nil(@public_api_key) do
"copy-link btn rounded-md transition-colors duration-300
bg-gray-500 hover:bg-gray-500 text-gray-300 cursor-not-allowed"
else
"copy-link btn rounded-md transition-colors duration-300
bg-blue-600 hover:bg-blue-700 text-white cursor-pointer"
end
}
class={"p-button p-component " <> if(is_nil(@public_api_key), do: "p-disabled", else: "")}
>
Copy
<span class="p-button-label">Copy</span>
</.button>
</div>
</div>
</div>
<div :if={@active_settings_tab == "balance"}>
<div class="stats w-full bg-primary text-primary-content">
<div class="stat">
@@ -687,10 +680,8 @@
<.button
:if={@active_settings_tab == "subscription" && not @is_adding_subscription?}
type="button"
disabled={
@map_subscriptions |> Enum.at(0) |> Map.get(:status) == :active &&
@map_subscriptions |> Enum.at(0) |> Map.get(:plan) != :alpha
}
disabled={@map_subscriptions |> Enum.at(0) |> Map.get(:status) == :active &&
@map_subscriptions |> Enum.at(0) |> Map.get(:plan) != :alpha}
phx-click="add_subscription"
>
Add subscription

View File

@@ -4,6 +4,7 @@ defmodule WandererAppWeb.Router do
use Plug.ErrorHandler
import PlugDynamic.Builder
import Logger
import WandererAppWeb.UserAuth,
warn: false,
@@ -118,6 +119,14 @@ defmodule WandererAppWeb.Router do
plug WandererAppWeb.Plugs.CheckApiDisabled
end
pipeline :api_character do
plug WandererAppWeb.Plugs.CheckCharacterApiDisabled
end
pipeline :api_acl do
plug WandererAppWeb.Plugs.CheckAclApiKey
end
scope "/api/map/systems-kills", WandererAppWeb do
pipe_through [:api, :api_map, :api_kills]
@@ -126,66 +135,77 @@ defmodule WandererAppWeb.Router do
scope "/api/map", WandererAppWeb do
pipe_through [:api, :api_map]
# GET /api/map/systems?map_id=... or ?slug=...
get "/systems", MapAPIController, :list_systems
# GET /api/map/system?id=... plus either map_id=... or slug=...
get "/system", MapAPIController, :show_system
# GET /api/map/characters?map_id=... or slug=...
get "/characters", MapAPIController, :tracked_characters_with_info
# GET /api/map/structure-timers?map_id=... or slug=... and optionally ?system_id=...
get "/structure-timers", MapAPIController, :show_structure_timers
get "/acls", MapAccessListAPIController, :index
post "/acls", MapAccessListAPIController, :create
end
scope "/api/characters", WandererAppWeb do
pipe_through [:api, :api_character]
get "/", CharactersAPIController, :index
end
scope "/api/acls", WandererAppWeb do
pipe_through [:api, :api_acl]
get "/:id", MapAccessListAPIController, :show
put "/:id", MapAccessListAPIController, :update
post "/:acl_id/members", AccessListMemberAPIController, :create
put "/:acl_id/members/:member_id", AccessListMemberAPIController, :update_role
delete "/:acl_id/members/:member_id", AccessListMemberAPIController, :delete
end
scope "/api/common", WandererAppWeb do
pipe_through [:api]
# GET /api/common/system-static-info?id=...
get "/system-static-info", CommonAPIController, :show_system_static
end
#
# Browser / blog stuff
#
scope "/", WandererAppWeb do
pipe_through [:browser, :blog, :redirect_if_user_is_authenticated]
get "/welcome", BlogController, :index
end
scope "/contacts", WandererAppWeb do
pipe_through [:browser, :blog]
get "/", BlogController, :contacts
end
scope "/changelog", WandererAppWeb do
pipe_through [:browser, :blog]
get "/", BlogController, :changelog
end
scope "/news", WandererAppWeb do
pipe_through [:browser, :blog]
get "/:slug", BlogController, :show
get "/", BlogController, :list
end
scope "/license", WandererAppWeb do
pipe_through [:browser, :blog]
get "/", BlogController, :license
end
#
# Auth
#
scope "/auth", WandererAppWeb do
pipe_through :browser
get "/signout", AuthController, :signout
get "/:provider", AuthController, :request
get "/:provider/callback", AuthController, :callback
end
#
# Admin
#
scope "/admin", WandererAppWeb do
pipe_through(:browser)
pipe_through(:admin_bauth)
@@ -207,53 +227,49 @@ defmodule WandererAppWeb.Router do
)
end
#
# Additional routes / Live sessions
#
scope "/", WandererAppWeb do
pipe_through(:browser)
get "/", RedirectController, :redirect_authenticated
get("/last", MapsController, :last)
get "/last", MapsController, :last
live_session :authenticated,
on_mount: [
{WandererAppWeb.UserAuth, :ensure_authenticated},
WandererAppWeb.Nav
] do
live("/access-lists/new", AccessListsLive, :create)
live("/access-lists/:id/edit", AccessListsLive, :edit)
live("/access-lists/:id/add-members", AccessListsLive, :add_members)
live("/access-lists/:id", AccessListsLive, :members)
live("/access-lists", AccessListsLive, :index)
live("/coming-soon", ComingLive, :index)
live("/tracking/:slug", CharactersTrackingLive, :characters)
live("/tracking", CharactersTrackingLive, :index)
live("/characters", CharactersLive, :index)
live("/characters/authorize", CharactersLive, :authorize)
live("/maps/new", MapsLive, :create)
live("/maps/:slug/edit", MapsLive, :edit)
live("/maps/:slug/settings", MapsLive, :settings)
live("/maps", MapsLive, :index)
live("/profile", ProfileLive, :index)
live("/profile/deposit", ProfileLive, :deposit)
live("/profile/subscribe", ProfileLive, :subscribe)
live("/:slug/audit", MapAuditLive, :index)
live("/:slug", MapLive, :index)
live "/access-lists/new", AccessListsLive, :create
live "/access-lists/:id/edit", AccessListsLive, :edit
live "/access-lists/:id/add-members", AccessListsLive, :add_members
live "/access-lists/:id", AccessListsLive, :members
live "/access-lists", AccessListsLive, :index
live "/coming-soon", ComingLive, :index
live "/tracking/:slug", CharactersTrackingLive, :characters
live "/tracking", CharactersTrackingLive, :index
live "/characters", CharactersLive, :index
live "/characters/authorize", CharactersLive, :authorize
live "/maps/new", MapsLive, :create
live "/maps/:slug/edit", MapsLive, :edit
live "/maps/:slug/settings", MapsLive, :settings
live "/maps", MapsLive, :index
live "/profile", ProfileLive, :index
live "/profile/deposit", ProfileLive, :deposit
live "/profile/subscribe", ProfileLive, :subscribe
live "/:slug/audit", MapAuditLive, :index
live "/:slug", MapLive, :index
end
end
# Enable LiveDashboard and Swoosh mailbox preview in development
if Application.compile_env(:wanderer_app, :dev_routes) do
# If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it.
# If your application does not have an admins-only section yet,
# you can use Plug.BasicAuth to set up some basic authentication
# as long as you are also using SSL (which you should anyway).
import Phoenix.LiveDashboard.Router
scope "/dev" do
pipe_through(:browser)
error_tracker_dashboard("/errors", as: :error_tracker_dev_dashboard)
live_dashboard("/dashboard", metrics: WandererAppWeb.Telemetry)
end
end

View File

@@ -0,0 +1,425 @@
%{
title: "User Guide: Characters & ACL API Endpoints",
author: "Wanderer Team",
cover_image_uri: "/images/news/02-20-acl-api/generate-key.png",
tags: ~w(acl characters guide interface),
description: "Learn how to retrieve and manage Access Lists and Characters through the Wanderer public APIs. This guide covers available endpoints, request examples, and sample responses."
}
---
## Introduction
Wanderers expanded public API now lets you retrieve **all characters** in the system and manage “Access Lists” (ACLs) for controlling visibility or permissions. These endpoints allow you to:
- Fetch a list of **all** EVE characters known to the system.
- List ACLs for a given map.
- Create new ACLs for maps (with automatic API key generation).
- Update existing ACLs.
- Add, remove, and change the roles of ACL members.
This guide provides step-by-step instructions, request/response examples, and details on how to authenticate each call.
---
## Authentication
Unless otherwise noted, these endpoints require a valid **Bearer** token. Pass it in the `Authorization` header:
```bash
Authorization: Bearer <REDACTED_TOKEN>
```
If the token is missing or invalid, youll receive a `401 Unauthorized` error.
_(No API key is required for some “common” endpoints, but ACL- and character-related endpoints require a valid token.)_
There are two types of tokens in use:
1. **Map API Token:** Available in the map settings. This token is used for map-specific endpoints (e.g. listing ACLs for a map and creating ACLs).
![Generate Map API Key](/images/news/01-05-map-public-api/generate-key.png "Generate Map API Key")
2. **ACL API Token:** Available in the create/edit ACL screen. This token is used for ACL member management endpoints.
![Generate ACL API Key](/images/news/02-20-acl-api/generate-key.png "Generate ACL API Key")
---
## Endpoints Overview
### 1. List **All** Characters
```bash
GET /api/characters
```
- **Description:** Returns a list of **all** characters known to Wanderer.
- **Toggle:** Controlled by the environment variable `WANDERER_CHARACTER_API_DISABLED` (default is `false`).
- **Example Request:**
```bash
curl -H "Authorization: Bearer <REDACTED_TOKEN>" \
"https://wanderer.example.com/api/characters"
```
- **Example Response (redacted):**
```json
{
"data": [
{
"id": "b374d9e6-47a7-4e20-85ad-d608809827b5",
"name": "Some Character",
"eve_id": "2122825111",
"corporation_name": "School of Applied Knowledge",
"alliance_name": null
},
{
"id": "6963bee6-eaa1-40e2-8200-4bc2fcbd7350",
"name": "Other Character",
"eve_id": "2122019111",
"corporation_name": "Some Corporation",
"alliance_name": null
}
...
]
}
```
Use the `eve_id` when referencing a character in ACL operations.
---
### 2. List ACLs for a Given Map
```bash
GET /api/map/acls?map_id=<UUID>
GET /api/map/acls?slug=<map-slug>
```
- **Description:** Lists all ACLs associated with a map, specified by either `map_id` (UUID) or `slug`.
- **Authentication:** Requires the Map API Token (available in map settings).
- **Example Request (using slug):**
```bash
curl -H "Authorization: Bearer <REDACTED_TOKEN>" \
"https://wanderer.example.com/api/map/acls?slug=mapname"
```
- **Example Response (redacted):**
```json
{
"data": [
{
"id": "19712899-ec3a-47b1-b73b-2bae221c5513",
"name": "aclName",
"description": null,
"owner_eve_id": "11111111111",
"inserted_at": "2025-02-13T03:32:25.144403Z",
"updated_at": "2025-02-13T03:32:25.144403Z",
}
]
}
```
---
### 3. Show a Specific ACL (Including Members)
```bash
GET /api/acls/:id
```
- **Description:** Fetches a single ACL by ID, with its members preloaded.
- **Authentication:** Requires the ACL API Token.
- **Example Request:**
```bash
curl -H "Authorization: Bearer <REDACTED_TOKEN>" \
"https://wanderer.example.com/api/acls/19712899-ec3a-47b1-b73b-2bae221c5513"
```
- **Example Response (redacted):**
```json
{
"data": {
"id": "19712899-ec3a-47b1-b73b-2bae221c5513",
"name": "aclName",
"description": null,
"owner_id": "d43a9083-2705-40c9-a314-f7f412346661",
"members": [
{
"id": "8d63ab1e-b44f-4e81-8227-8fb8d928dad8",
"name": "Other Character",
"role": "admin",
"inserted_at": "2025-02-13T03:33:32.332598Z",
"updated_at": "2025-02-13T03:33:36.644520Z"
},
...
]
}
}
```
---
### 4. Create a New ACL Associated with a Map
```bash
POST /api/map/acls
```
- **Description:** Creates a new ACL for a map and generates a new ACL API key. The map record tracks its ACLs.
- **Required Query Parameter:** Either `map_id` (UUID) or `slug` (map slug).
- **Request Body Example:**
```json
{
"acl": {
"name": "New ACL",
"description": "Optional description",
"owner_eve_id": "EXTERNAL_EVE_ID"
}
}
```
- `owner_eve_id` must be the external EVE id (the `eve_id` from `/api/characters`).
- **Example Request (using map slug):**
```bash
curl -X POST \
-H "Authorization: Bearer <MAP_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"acl": {
"name": "New ACL",
"description": "Optional description",
"owner_eve_id": "EXTERNAL_EVE_ID"
}
}' \
"https://wanderer.example.com/api/map/acls?slug=mapname"
```
- **Example Request (using map UUID):**
```bash
curl -X POST \
-H "Authorization: Bearer <MAP_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"acl": {
"name": "New ACL",
"description": "Optional description",
"owner_eve_id": "EXTERNAL_EVE_ID"
}
}' \
"https://wanderer.example.com/api/map/acls?map_id=YOUR_MAP_UUID"
```
- **Example Response (redacted):**
```json
{
"data": {
"id": "NEW_ACL_UUID",
"name": "New ACL",
"description": "Optional description",
"owner_id": "OWNER_ID",
"api_key": "GENERATED_ACL_API_KEY",
"inserted_at": "2025-02-14T17:00:00Z",
"updated_at": "2025-02-14T17:00:00Z",
"members": []
}
}
```
---
### 5. Update an ACL
```bash
PUT /api/acls/:id
```
- **Description:** Updates an existing ACL (e.g. name, description, api_key).
The update endpoint fetches the ACL record first and then applies the update.
- **Authentication:** Requires the ACL API Token.
- **Example Request:**
```bash
curl -X PUT \
-H "Authorization: Bearer <ACL_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"acl": {
"name": "Updated ACL Name",
"description": "This is the updated description",
"api_key": "EXISTING_ACL_API_KEY"
}
}' \
"https://wanderer.example.com/api/acls/ACL_UUID"
```
- **Example Response (redacted):**
```json
{
"data": {
"id": "ACL_UUID",
"name": "Updated ACL Name",
"description": "This is the updated description",
"owner_id": "OWNER_ID",
"api_key": "EXISTING_ACL_API_KEY",
"inserted_at": "2025-02-14T16:49:13.423556Z",
"updated_at": "2025-02-14T17:22:51.343784Z",
"members": []
}
}
```
---
### 6. Add a Member to an ACL
```bash
POST /api/acls/:acl_id/members
```
- **Description:** Adds a new member (character, corporation, or alliance) to the specified ACL.
- **Authentication:** Requires the ACL API Token.
- **Request Body Example:**
```json
{
"member": {
"name": "New Member",
"eve_character_id": "EXTERNAL_EVE_ID",
"role": "viewer"
}
}
```
- `eve_character_id` is the characters external EVE id.
- **Example Request:**
```bash
curl -X POST \
-H "Authorization: Bearer <ACL_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"member": {
"name": "New Member",
"eve_character_id": "EXTERNAL_EVE_ID",
"role": "viewer"
}
}' \
"https://wanderer.example.com/api/acls/ACL_UUID/members"
```
- **Example Response (redacted):**
```json
{
"data": {
"id": "MEMBERSHIP_UUID",
"name": "New Member",
"role": "viewer",
"inserted_at": "...",
"updated_at": "..."
}
}
```
---
### 7. Change a Members Role
```bash
PUT /api/acls/:acl_id/members/:member_id
```
- **Description:** Updates an ACL members role (e.g. from `viewer` to `admin`).
The `:member_id` is the external EVE id of the character.
- **Authentication:** Requires the ACL API Token.
- **Request Body Example:**
```json
{
"member": {
"role": "admin"
}
}
```
- **Example Request:**
```bash
curl -X PUT \
-H "Authorization: Bearer <ACL_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"member": {
"role": "admin"
}
}' \
"https://wanderer.example.com/api/acls/ACL_UUID/members/EXTERNAL_EVE_ID"
```
- **Example Response (redacted):**
```json
{
"data": {
"id": "MEMBERSHIP_UUID",
"name": "New Member",
"role": "admin",
...
}
}
```
---
### 8. Remove a Member from an ACL
```bash
DELETE /api/acls/:acl_id/members/:member_id
```
- **Description:** Removes the member with the specified external EVE id from the ACL.
- **Authentication:** Requires the ACL API Token.
- **Example Request:**
```bash
curl -X DELETE \
-H "Authorization: Bearer <ACL_API_TOKEN>" \
"https://wanderer.example.com/api/acls/ACL_UUID/members/EXTERNAL_EVE_ID"
```
- **Example Response:**
```json
{ "ok": true }
```
---
## Conclusion
This guide outlines how to:
1. **List** all characters (`GET /api/characters`) so you can pick a valid character to add to your ACL.
2. **List** ACLs for a specified map (`GET /api/map/acls?map_id=<UUID>` or `?slug=<map-slug>`).
3. **Show** ACL details, including its members (`GET /api/acls/:id`).
4. **Create** a new ACL for a map (`POST /api/map/acls`), which generates a new ACL API key.
5. **Update** an existing ACL (`PUT /api/acls/:id`).
6. **Add** members (characters, corporations, alliances) to an ACL (`POST /api/acls/:acl_id/members`).
7. **Change** a members role (`PUT /api/acls/:acl_id/members/:member_id`).
8. **Remove** a member from an ACL (`DELETE /api/acls/:acl_id/members/:member_id`).
By following these request patterns, you can manage your ACL resources in a fully programmatic fashion. If you have any questions, feel free to reach out to the Wanderer Team.
Fly safe,
**WANDERER TEAM**

View File

@@ -0,0 +1,21 @@
defmodule WandererApp.Repo.Migrations.AddAclApiKey do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:access_lists_v1) do
add :api_key, :text
end
end
def down do
alter table(:access_lists_v1) do
remove :api_key
end
end
end

View File

@@ -0,0 +1,108 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"primary_key?": true,
"references": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "description",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "api_key",
"type": "text"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "access_lists_v1_owner_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "character_v1"
},
"size": null,
"source": "owner_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "5118AF0DEBEEED63DC30565ECFFEDF682876FAD476AF2796E973C6883E4054E0",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.WandererApp.Repo",
"schema": null,
"table": "access_lists_v1"
}