diff --git a/config/config.exs b/config/config.exs index 74b2303..f57e0cc 100644 --- a/config/config.exs +++ b/config/config.exs @@ -26,7 +26,6 @@ config :pinchflat, basic_auth_username: "", basic_auth_password: "", expose_feed_endpoints: false, - api_token: "", file_watcher_poll_interval: 1000, timezone: "UTC", base_route_path: "/" diff --git a/config/runtime.exs b/config/runtime.exs index e82faa9..5624bfe 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -23,8 +23,7 @@ end config :pinchflat, basic_auth_username: System.get_env("BASIC_AUTH_USERNAME"), - basic_auth_password: System.get_env("BASIC_AUTH_PASSWORD"), - api_token: System.get_env("PINCHFLAT_API_TOKEN") + basic_auth_password: System.get_env("BASIC_AUTH_PASSWORD") arch_string = to_string(:erlang.system_info(:system_architecture)) diff --git a/config/test.exs b/config/test.exs index 4a42e62..fff9920 100644 --- a/config/test.exs +++ b/config/test.exs @@ -12,10 +12,6 @@ config :pinchflat, config :pinchflat, Oban, testing: :manual -# API token is not set in tests by default — allows tests to run without auth. -# Individual tests that need auth can set it via Application.put_env. -config :pinchflat, :api_token, nil - # Configure your database # # The MIX_TEST_PARTITION environment variable can be used diff --git a/docs/API.md b/docs/API.md index f500e9b..459d03b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -5,22 +5,17 @@ MCP server integration and other automation use cases. ## API Authentication -The API is protected by a Bearer token. Set the `PINCHFLAT_API_TOKEN` -environment variable to a secure value: +The API always requires a Bearer token — there is no open-access mode. +Generate a token from the web UI under **Settings → API Access**: -```bash -docker run \ - -e PINCHFLAT_API_TOKEN=your-secret-token \ - ... -``` - -If the token is not set, API authentication is disabled (useful for -development, but **not recommended for production**). +1. Navigate to Settings +2. Click "Generate API Token" +3. Copy the token (it won't be shown in full again) Clients must send the token in the `Authorization` header: ``` -Authorization: Bearer your-secret-token +Authorization: Bearer *** to regenerate or revoke the token at any time. ``` ## API Endpoints diff --git a/lib/pinchflat/settings/setting.ex b/lib/pinchflat/settings/setting.ex index f2a6b0a..ee7f33a 100644 --- a/lib/pinchflat/settings/setting.ex +++ b/lib/pinchflat/settings/setting.ex @@ -17,7 +17,8 @@ defmodule Pinchflat.Settings.Setting do :youtube_api_key, :extractor_sleep_interval_seconds, :download_throughput_limit, - :restrict_filenames + :restrict_filenames, + :api_token ] @required_fields [ @@ -40,6 +41,7 @@ defmodule Pinchflat.Settings.Setting do # This is a string because it accepts values like "100K" or "4.2M" field :download_throughput_limit, :string field :restrict_filenames, :boolean, default: false + field :api_token, :string field :video_codec_preference, :string field :audio_codec_preference, :string diff --git a/lib/pinchflat_web/api_auth_plug.ex b/lib/pinchflat_web/api_auth_plug.ex index b3c552e..d7fa442 100644 --- a/lib/pinchflat_web/api_auth_plug.ex +++ b/lib/pinchflat_web/api_auth_plug.ex @@ -2,21 +2,21 @@ defmodule PinchflatWeb.ApiAuthPlug do @moduledoc """ Authenticates API requests using a Bearer token. - The token is read from the `PINCHFLAT_API_TOKEN` environment variable. - If the environment variable is not set, API authentication is disabled - (useful for development and testing). In production, you should always - set this to a secure value. + The token is stored in the settings database and managed via the web UI. + A token must be generated before API access is possible — the API is + always authenticated, there is no open-access mode. Clients should send the token in the Authorization header: Authorization: Bearer """ import Plug.Conn + alias Pinchflat.Settings def init(opts), do: opts def call(conn, _opts) do - expected_token = Application.get_env(:pinchflat, :api_token) + expected_token = Settings.get!(:api_token) if credential_set?(expected_token) do case get_req_header(conn, "authorization") do @@ -31,8 +31,8 @@ defmodule PinchflatWeb.ApiAuthPlug do send_unauthorized(conn) end else - # No API token configured — allow access (dev/test mode) - conn + # No API token has been generated yet — deny all access + send_unauthorized(conn, "API token not configured. Generate one in Settings.") end end @@ -40,10 +40,10 @@ defmodule PinchflatWeb.ApiAuthPlug do credential && credential != "" end - defp send_unauthorized(conn) do + defp send_unauthorized(conn, message \\ "Unauthorized") do conn |> put_resp_content_type("application/json") - |> send_resp(401, ~s({"errors":{"detail":"Unauthorized"}})) + |> send_resp(401, ~s({"errors":{"detail":"#{message}"}})) |> halt() end end diff --git a/lib/pinchflat_web/controllers/settings/setting_controller.ex b/lib/pinchflat_web/controllers/settings/setting_controller.ex index 5e009c1..bb49a79 100644 --- a/lib/pinchflat_web/controllers/settings/setting_controller.ex +++ b/lib/pinchflat_web/controllers/settings/setting_controller.ex @@ -28,6 +28,36 @@ defmodule PinchflatWeb.Settings.SettingController do render(conn, "app_info.html") end + def generate_api_token(conn, _params) do + token = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false) + + case Settings.set(api_token: token) do + {:ok, _} -> + conn + |> put_flash(:info, "API token generated successfully.") + |> redirect(to: ~p"/settings") + + {:error, _} -> + conn + |> put_flash(:error, "Failed to generate API token.") + |> redirect(to: ~p"/settings") + end + end + + def revoke_api_token(conn, _params) do + case Settings.set(api_token: nil) do + {:ok, _} -> + conn + |> put_flash(:info, "API token revoked.") + |> redirect(to: ~p"/settings") + + {:error, _} -> + conn + |> put_flash(:error, "Failed to revoke API token.") + |> redirect(to: ~p"/settings") + end + end + def download_logs(conn, _params) do log_path = Application.get_env(:pinchflat, :log_path) diff --git a/lib/pinchflat_web/controllers/settings/setting_html/setting_form.html.heex b/lib/pinchflat_web/controllers/settings/setting_html/setting_form.html.heex index c59031f..275b0a4 100644 --- a/lib/pinchflat_web/controllers/settings/setting_html/setting_form.html.heex +++ b/lib/pinchflat_web/controllers/settings/setting_html/setting_form.html.heex @@ -26,6 +26,44 @@ )} +
+

+ API Access +

+

+ Generate a token to authenticate requests to the /api/v1 REST API. + Required for MCP server integration and other programmatic clients. + Send it as Authorization: Bearer <token>. +

+ +
+ <%= if Settings.get!(:api_token) do %> +
+ + {String.slice(Settings.get!(:api_token), 0, 12)}•••••••• + + <.link href={~p"/settings/generate_api_token"} method="post"> + <.button rounding="rounded-lg" class="bg-warning hover:bg-warning/80">Regenerate Token + + <.link + href={~p"/settings/revoke_api_token"} + method="post" + data-confirm="Are you sure? This will immediately disable all API access." + > + <.button rounding="rounded-lg" class="bg-danger hover:bg-danger/80">Revoke Token + +
+ <% else %> +

+ No API token configured. The API is currently inaccessible. +

+ <.link href={~p"/settings/generate_api_token"} method="post"> + <.button rounding="rounded-lg" class="bg-primary hover:bg-primary/80">Generate API Token + + <% end %> +
+
+

diff --git a/lib/pinchflat_web/router.ex b/lib/pinchflat_web/router.ex index 86d8ba8..4f889e7 100644 --- a/lib/pinchflat_web/router.ex +++ b/lib/pinchflat_web/router.ex @@ -52,6 +52,8 @@ defmodule PinchflatWeb.Router do resources "/search", Searches.SearchController, only: [:show], singleton: true resources "/settings", Settings.SettingController, only: [:show, :update], singleton: true + post "/settings/generate_api_token", Settings.SettingController, :generate_api_token + post "/settings/revoke_api_token", Settings.SettingController, :revoke_api_token get "/app_info", Settings.SettingController, :app_info get "/download_logs", Settings.SettingController, :download_logs diff --git a/priv/repo/migrations/20260704114239_add_api_token_to_settings.exs b/priv/repo/migrations/20260704114239_add_api_token_to_settings.exs new file mode 100644 index 0000000..52228fe --- /dev/null +++ b/priv/repo/migrations/20260704114239_add_api_token_to_settings.exs @@ -0,0 +1,9 @@ +defmodule Pinchflat.Repo.Migrations.AddApiTokenToSettings do + use Ecto.Migration + + def change do + alter table(:settings) do + add :api_token, :string + end + end +end diff --git a/test/pinchflat_web/api_auth_plug_test.exs b/test/pinchflat_web/api_auth_plug_test.exs index 4540a4f..2e8cdae 100644 --- a/test/pinchflat_web/api_auth_plug_test.exs +++ b/test/pinchflat_web/api_auth_plug_test.exs @@ -2,24 +2,34 @@ defmodule PinchflatWeb.ApiAuthPlugTest do use PinchflatWeb.ConnCase import Pinchflat.ProfilesFixtures + alias Pinchflat.Settings setup do - # Ensure api_token is not set by default in tests - Application.put_env(:pinchflat, :api_token, nil) + # Ensure api_token is cleared in DB for each test + Settings.set(api_token: nil) :ok end describe "when no api_token is configured" do - test "allows access without Authorization header", %{conn: conn} do + test "returns 401 without Authorization header", %{conn: conn} do conn = get(conn, ~p"/api/v1/media_profiles") - assert %{status: 200} = conn + + assert %{status: 401} = conn + end + + test "returns 401 even with a Bearer token", %{conn: conn} do + conn = + conn + |> put_req_header("authorization", "Bearer some-token") + |> get(~p"/api/v1/media_profiles") + + assert %{status: 401} = conn end end describe "when api_token is configured" do setup do - Application.put_env(:pinchflat, :api_token, "secret-token-123") - on_exit(fn -> Application.put_env(:pinchflat, :api_token, nil) end) + Settings.set(api_token: "secret-token-123") :ok end diff --git a/test/pinchflat_web/controllers/api/v1/api_media_item_controller_test.exs b/test/pinchflat_web/controllers/api/v1/api_media_item_controller_test.exs index 24bf0b7..5656cc7 100644 --- a/test/pinchflat_web/controllers/api/v1/api_media_item_controller_test.exs +++ b/test/pinchflat_web/controllers/api/v1/api_media_item_controller_test.exs @@ -1,6 +1,16 @@ defmodule PinchflatWeb.Api.V1.ApiMediaItemControllerTest do use PinchflatWeb.ConnCase + setup do + Pinchflat.Settings.set(api_token: "test-token") + + conn = + build_conn() + |> put_req_header("authorization", "Bearer test-token") + + {:ok, conn: conn} + end + import Pinchflat.MediaFixtures import Pinchflat.SourcesFixtures import Pinchflat.ProfilesFixtures diff --git a/test/pinchflat_web/controllers/api/v1/api_media_profile_controller_test.exs b/test/pinchflat_web/controllers/api/v1/api_media_profile_controller_test.exs index e3d981d..cf83615 100644 --- a/test/pinchflat_web/controllers/api/v1/api_media_profile_controller_test.exs +++ b/test/pinchflat_web/controllers/api/v1/api_media_profile_controller_test.exs @@ -1,6 +1,16 @@ defmodule PinchflatWeb.Api.V1.ApiMediaProfileControllerTest do use PinchflatWeb.ConnCase + setup do + Pinchflat.Settings.set(api_token: "test-token") + + conn = + build_conn() + |> put_req_header("authorization", "Bearer test-token") + + {:ok, conn: conn} + end + import Pinchflat.ProfilesFixtures describe "GET /api/v1/media_profiles" do diff --git a/test/pinchflat_web/controllers/api/v1/api_setting_controller_test.exs b/test/pinchflat_web/controllers/api/v1/api_setting_controller_test.exs index 563e5d7..20b1b34 100644 --- a/test/pinchflat_web/controllers/api/v1/api_setting_controller_test.exs +++ b/test/pinchflat_web/controllers/api/v1/api_setting_controller_test.exs @@ -1,6 +1,16 @@ defmodule PinchflatWeb.Api.V1.ApiSettingControllerTest do use PinchflatWeb.ConnCase + setup do + Pinchflat.Settings.set(api_token: "test-token") + + conn = + build_conn() + |> put_req_header("authorization", "Bearer test-token") + + {:ok, conn: conn} + end + import Pinchflat.ProfilesFixtures alias Pinchflat.Settings diff --git a/test/pinchflat_web/controllers/api/v1/api_source_controller_test.exs b/test/pinchflat_web/controllers/api/v1/api_source_controller_test.exs index d0f6c77..f88b889 100644 --- a/test/pinchflat_web/controllers/api/v1/api_source_controller_test.exs +++ b/test/pinchflat_web/controllers/api/v1/api_source_controller_test.exs @@ -1,6 +1,16 @@ defmodule PinchflatWeb.Api.V1.ApiSourceControllerTest do use PinchflatWeb.ConnCase + setup do + Pinchflat.Settings.set(api_token: "test-token") + + conn = + build_conn() + |> put_req_header("authorization", "Bearer test-token") + + {:ok, conn: conn} + end + import Pinchflat.MediaFixtures import Pinchflat.SourcesFixtures import Pinchflat.ProfilesFixtures diff --git a/test/pinchflat_web/controllers/api/v1/api_task_controller_test.exs b/test/pinchflat_web/controllers/api/v1/api_task_controller_test.exs index e18e62d..a69ab8b 100644 --- a/test/pinchflat_web/controllers/api/v1/api_task_controller_test.exs +++ b/test/pinchflat_web/controllers/api/v1/api_task_controller_test.exs @@ -1,6 +1,16 @@ defmodule PinchflatWeb.Api.V1.ApiTaskControllerTest do use PinchflatWeb.ConnCase + setup do + Pinchflat.Settings.set(api_token: "test-token") + + conn = + build_conn() + |> put_req_header("authorization", "Bearer test-token") + + {:ok, conn: conn} + end + import Pinchflat.SourcesFixtures import Pinchflat.MediaFixtures