Compare commits

..

19 Commits

Author SHA1 Message Date
hermes-agent 544c6ffc61 flake: use self.outPath instead of fetchFromGitea with hardcoded rev
Nix Flake Check / Nix Flake Check (push) Successful in 1m29s
Build and Test / Build and Test (push) Successful in 7m49s
The overlay was pinning a specific commit via fetchFromGitea, requiring
manual hash updates every time the fork changed. Now uses self.outPath
which points to the flake's own source — the consuming repo's flake.lock
tracks the rev and hash automatically.
2026-07-12 09:38:57 +00:00
hermes-agent 4734e94f26 flake: update overlay rev to pagination fix (need correct hash)
Nix Flake Check / Nix Flake Check (push) Failing after 1m22s
Build and Test / Build and Test (push) Successful in 8m9s
2026-07-11 18:56:55 +00:00
hermes-agent a3b75771e1 fix: split long from() query to satisfy mix formatter
Nix Flake Check / Nix Flake Check (push) Successful in 1m28s
Build and Test / Build and Test (push) Successful in 7m31s
2026-07-09 08:25:04 +00:00
hermes-agent 5667ea67c3 fix: format Elixir code to pass mix formatter check
Nix Flake Check / Nix Flake Check (push) Successful in 1m33s
Build and Test / Build and Test (push) Failing after 7m46s
2026-07-09 06:43:19 +00:00
hermes-agent a2da433cdc api: add pagination (limit/offset) and downloaded filter to media endpoint
Nix Flake Check / Nix Flake Check (push) Successful in 1m34s
Build and Test / Build and Test (push) Failing after 7m49s
The /api/v1/media endpoint was loading ALL media items from the database
with no pagination, causing timeouts with large libraries (1800+ items
per source). Now supports:
- ?limit=N (default 50)
- ?offset=N (default 0)
- ?downloaded=true (filter to only downloaded media)
- Ordered by uploaded_at desc
2026-07-08 21:54:39 +00:00
hermes-agent 75fb7dd9d8 Update flake.nix rev/hash to include copy button fix
Nix Flake Check / Nix Flake Check (push) Successful in 1m29s
Build and Test / Build and Test (push) Successful in 7m30s
Previous rev (5280baf) was the commit before the fix. Now points
to fc39a7d which has the data-token attribute fix.
2026-07-04 21:45:07 +00:00
hermes-agent fc39a7df6c Fix: copy button was storing literal template string
Nix Flake Check / Nix Flake Check (push) Successful in 1m30s
Build and Test / Build and Test (push) Successful in 7m42s
HEEX doesn't interpolate {} inside quoted attribute values.
Use data-token attribute + .dataset.token instead.
2026-07-04 18:06:54 +00:00
hermes-agent 049773ab34 Update flake rev/hash for reveal/copy token UI
Build and Test / Build and Test (push) Failing after 6m3s
Nix Flake Check / Nix Flake Check (push) Successful in 1m30s
2026-07-04 17:39:04 +00:00
hermes-agent 5280baf33d Add reveal/copy buttons for API token in settings UI
Nix Flake Check / Nix Flake Check (push) Successful in 1m51s
Build and Test / Build and Test (push) Successful in 8m30s
The token was only shown masked (first 12 chars + bullets) with no
way to see or copy the full value. Added:
- Show/Hide toggle to reveal the full token
- Copy button that writes the token to clipboard
Both use Alpine.js (already used in the template).
2026-07-04 14:02:32 +00:00
hermes-agent 0e0fd20da7 Update flake.nix rev and hash for mandatory API auth commit
Build and Test / Build and Test (push) Failing after 5m53s
Nix Flake Check / Nix Flake Check (push) Successful in 1m29s
2026-07-04 11:52:33 +00:00
hermes-agent 75f75d8d19 Make API auth mandatory, manage tokens via web UI
Nix Flake Check / Nix Flake Check (push) Successful in 1m55s
Build and Test / Build and Test (push) Successful in 8m25s
BREAKING CHANGE: API authentication is now always required. The
PINCHFLAT_API_TOKEN env var is no longer used. Instead, tokens are
stored in the database and managed via Settings → API Access.

Changes:
- Add api_token column to settings table (migration)
- ApiAuthPlug reads from DB; returns 401 if no token configured
- Add API Access section to Settings page with generate/regenerate/revoke
- Add POST /settings/generate_api_token and /settings/revoke_api_token
- Remove api_token from config.exs and runtime.exs
- Update all API controller tests to set auth token in setup
- Update auth plug tests for mandatory authentication
- 1008 tests pass, zero warnings
2026-07-04 11:51:40 +00:00
hermes-agent 7ec48045ce Add Nix flake CI: eval check + hash verification
Build and Test / Build and Test (push) Successful in 2m6s
Nix Flake Check / Nix Flake Check (push) Successful in 1m32s
Checks that the flake evaluates, and verifies the source, mixFodDeps,
and yarnOfflineCache hashes are correct. Does not do a full nix build
(which would be too slow for CI) — the hash checks catch the most
common issues before merging to config-nixos.
2026-07-04 10:58:26 +00:00
hermes-agent 254b712747 Add flake.nix exporting overlay for custom pinchflat package
Build and Test / Build and Test (push) Failing after 6m55s
The overlay overrides pkgs.pinchflat to build from this fork (with REST
API) instead of upstream kieraneglin/pinchflat. Consumed by config-nixos
as a flake input: pinchflat.overlays.default
2026-07-04 10:54:44 +00:00
hermes-agent 8e0ec4b8b9 Format ci.yml and API.md with prettier
Build and Test / Build and Test (push) Successful in 8m8s
2026-07-04 10:12:11 +00:00
hermes-agent 517f3f0329 CI: use docker compose build --no-cache to prevent stale images
Build and Test / Build and Test (push) Failing after 7m34s
Replace build-push-action with docker compose build --no-cache to
ensure the image always contains the latest code. Remove buildx action
since we don't need it anymore.
2026-07-04 10:03:31 +00:00
hermes-agent 1d3bb1919b Disable Docker GHA cache to fix stale image builds in Gitea Actions
Build, Lint, and Test / Build, Lint, and Test (push) Failing after 9m0s
The type=gha cache was serving stale COPY layers, so the Docker image
contained old code even after pushes. Removing cache-from/cache-to
forces a fresh build every time.
2026-07-04 09:52:27 +00:00
hermes-agent 5647e5642a Fix CI: remove unused aliases, run mix format, install root yarn deps
Build, Lint, and Test / Build, Lint, and Test (push) Failing after 1m21s
- Remove unused aliases that caused warnings-as-errors in EX_CHECK mode
- Run mix format to fix formatter check
- Add root-level yarn install to CI for prettier (used by mix check)
- All 1007 tests pass, zero warnings
2026-07-04 09:48:47 +00:00
hermes-agent a891cb8902 Fix CI for Gitea Actions: remove volume mount, run all commands in container
Build, Lint, and Test / Build, Lint, and Test (push) Failing after 1m36s
The docker-compose.ci.yml mounted '.:/app' which works on GitHub Actions
(where the workspace is the repo root) but not on Gitea Actions (where
the workspace path differs). The built Docker image already has all files
from COPY . ./ so the volume mount is unnecessary for CI.

Also moved yarn install inside the container and run full test suite.
2026-07-04 09:42:10 +00:00
hermes-agent d111a3ffd5 Merge pull request 'Add JSON REST API for MCP integration' (#1) from feature/api into master
Build, Lint, and Test / Build, Lint, and Test (push) Failing after 1m2s
Merge feature/api: Add REST API with Bearer token auth, fix all test failures
2026-07-04 11:36:34 +02:00
28 changed files with 427 additions and 124 deletions
+14 -24
View File
@@ -1,4 +1,4 @@
name: Build, Lint, and Test name: Build and Test
on: on:
push: push:
@@ -12,38 +12,28 @@ on:
jobs: jobs:
build-and-test: build-and-test:
name: Build, Lint, and Test name: Build and Test
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')" if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')"
env: env:
COMPOSE_FILE: ./docker-compose.ci.yml COMPOSE_FILE: ./docker-compose.ci.yml
MIX_ENV: test MIX_ENV: test
DOCKER_BUILDKIT: 1
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Pull prebuilt images - name: Build and run Docker image
run: docker compose pull
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/dev.Dockerfile
load: true
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Run Docker image
run: docker compose up --detach
- name: Install Elixir and JS deps
run: | run: |
docker compose exec -T phx mix deps.get && yarn install && cd assets && yarn install && cd .. docker compose build --no-cache
docker compose up --detach
- name: Install deps
run: |
docker compose exec -T phx mix deps.get
docker compose exec -T phx yarn install
docker compose exec -T phx sh -c "cd assets && yarn install"
- name: Create and Migrate database - name: Create and Migrate database
run: | run: |
@@ -53,5 +43,5 @@ jobs:
- name: Run code checks - name: Run code checks
run: docker compose exec -T phx mix check --no-fix --no-retry run: docker compose exec -T phx mix check --no-fix --no-retry
- name: Run API tests - name: Run tests
run: docker compose exec -T phx mix test test/pinchflat_web/controllers/api test/pinchflat_web/api_auth_plug_test.exs --trace run: docker compose exec -T phx mix test --trace
+38
View File
@@ -0,0 +1,38 @@
name: Nix Flake Check
on:
push:
branches:
- master
- feature/*
pull_request:
branches:
- master
workflow_dispatch:
jobs:
flake-check:
name: Nix Flake Check
runs-on: ubuntu-latest
if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')"
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Nix
uses: cachix/install-nix-action@v27
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Check flake evaluates
run: nix flake check --no-build
- name: Verify source hash
run: nix build .#pinchflat.src --no-link --print-out-paths
- name: Verify mix deps hash
run: nix build .#pinchflat.mixFodDeps --no-link --print-out-paths
- name: Verify yarn cache hash
run: nix build .#pinchflat.yarnOfflineCache --no-link --print-out-paths
-1
View File
@@ -26,7 +26,6 @@ config :pinchflat,
basic_auth_username: "", basic_auth_username: "",
basic_auth_password: "", basic_auth_password: "",
expose_feed_endpoints: false, expose_feed_endpoints: false,
api_token: "",
file_watcher_poll_interval: 1000, file_watcher_poll_interval: 1000,
timezone: "UTC", timezone: "UTC",
base_route_path: "/" base_route_path: "/"
+1 -2
View File
@@ -23,8 +23,7 @@ end
config :pinchflat, config :pinchflat,
basic_auth_username: System.get_env("BASIC_AUTH_USERNAME"), basic_auth_username: System.get_env("BASIC_AUTH_USERNAME"),
basic_auth_password: System.get_env("BASIC_AUTH_PASSWORD"), basic_auth_password: System.get_env("BASIC_AUTH_PASSWORD")
api_token: System.get_env("PINCHFLAT_API_TOKEN")
arch_string = to_string(:erlang.system_info(:system_architecture)) arch_string = to_string(:erlang.system_info(:system_architecture))
-4
View File
@@ -12,10 +12,6 @@ config :pinchflat,
config :pinchflat, Oban, testing: :manual 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 # Configure your database
# #
# The MIX_TEST_PARTITION environment variable can be used # The MIX_TEST_PARTITION environment variable can be used
-5
View File
@@ -5,11 +5,6 @@ services:
dockerfile: ./docker/dev.Dockerfile dockerfile: ./docker/dev.Dockerfile
environment: environment:
- MIX_ENV=test - MIX_ENV=test
volumes:
- '.:/app'
# These lines ensure the deps can be saved as build artifacts for caching
- '/app/deps'
- '/app/_build'
ports: ports:
- '4008:4008' - '4008:4008'
command: tail -F /dev/null command: tail -F /dev/null
+11 -16
View File
@@ -5,22 +5,17 @@ MCP server integration and other automation use cases.
## API Authentication ## API Authentication
The API is protected by a Bearer token. Set the `PINCHFLAT_API_TOKEN` The API always requires a Bearer token — there is no open-access mode.
environment variable to a secure value: Generate a token from the web UI under **Settings → API Access**:
```bash 1. Navigate to Settings
docker run \ 2. Click "Generate API Token"
-e PINCHFLAT_API_TOKEN=your-secret-token \ 3. Copy the token (it won't be shown in full again)
...
```
If the token is not set, API authentication is disabled (useful for
development, but **not recommended for production**).
Clients must send the token in the `Authorization` header: 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 ## API Endpoints
@@ -30,7 +25,7 @@ All endpoints are under `/api/v1`.
### Sources ### Sources
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| | ------ | --------------------------------------------------- | ------------------------------------ |
| GET | `/api/v1/sources` | List all sources | | GET | `/api/v1/sources` | List all sources |
| GET | `/api/v1/sources/:id` | Get a source with its pending tasks | | GET | `/api/v1/sources/:id` | Get a source with its pending tasks |
| POST | `/api/v1/sources` | Create a new source | | POST | `/api/v1/sources` | Create a new source |
@@ -45,7 +40,7 @@ All endpoints are under `/api/v1`.
### Media Items ### Media Items
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| | ------ | --------------------------------------------- | ----------------------------------------------------- |
| GET | `/api/v1/media` | List all media items (optional `?source_id=X` filter) | | GET | `/api/v1/media` | List all media items (optional `?source_id=X` filter) |
| GET | `/api/v1/media/search?q=query` | Search media items | | GET | `/api/v1/media/search?q=query` | Search media items |
| GET | `/api/v1/media/:id` | Get a media item | | GET | `/api/v1/media/:id` | Get a media item |
@@ -56,7 +51,7 @@ All endpoints are under `/api/v1`.
### Media Profiles ### Media Profiles
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| | ------ | ---------------------------- | ----------------------- |
| GET | `/api/v1/media_profiles` | List all media profiles | | GET | `/api/v1/media_profiles` | List all media profiles |
| GET | `/api/v1/media_profiles/:id` | Get a media profile | | GET | `/api/v1/media_profiles/:id` | Get a media profile |
| POST | `/api/v1/media_profiles` | Create a media profile | | POST | `/api/v1/media_profiles` | Create a media profile |
@@ -66,7 +61,7 @@ All endpoints are under `/api/v1`.
### Settings ### Settings
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| | ------ | ------------------ | ----------------------------------------- |
| GET | `/api/v1/settings` | Get settings (route_token excluded) | | GET | `/api/v1/settings` | Get settings (route_token excluded) |
| PUT | `/api/v1/settings` | Update settings | | PUT | `/api/v1/settings` | Update settings |
| GET | `/api/v1/app_info` | Get app info (version, environment, etc.) | | GET | `/api/v1/app_info` | Get app info (version, environment, etc.) |
@@ -74,7 +69,7 @@ All endpoints are under `/api/v1`.
### Tasks ### Tasks
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| | ------ | ------------------- | ----------------------------------------------- |
| GET | `/api/v1/tasks` | List all tasks (optional `?source_id=X` filter) | | GET | `/api/v1/tasks` | List all tasks (optional `?source_id=X` filter) |
| GET | `/api/v1/tasks/:id` | Get a task | | GET | `/api/v1/tasks/:id` | Get a task |
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1782959384,
"narHash": "sha256-xnJJk+ct+D2+wdRxj1wk36w5zV9RVESwRqcklPdt3fM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "65179426c83bb3f6bc14898b42ea1c6f01d374b0",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+57
View File
@@ -0,0 +1,57 @@
{
description = "Pinchflat fork with JSON REST API for MCP integration";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs =
{ self, nixpkgs, ... }:
let
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
in
{
overlays = {
default = final: prev: {
pinchflat = prev.pinchflat.overrideAttrs (
old: {
# Use the flake's own source — rev and hash are tracked
# by flake.lock in the consuming repo, no manual updates needed
src = self.outPath;
# Deps hashes are identical to upstream — mix.lock and yarn.lock
# are unchanged in this fork. If they drift, nix build will
# print the correct hash to use.
mixFodDeps = old.mixFodDeps.overrideAttrs (_: {
hash = "sha256-7zLlOzBJcvookYX/4SNC0O1Yr62LIKH9R8rONl3diSs=";
});
yarnOfflineCache = old.yarnOfflineCache.overrideAttrs (_: {
sha256 = "sha256-xJL+qcohtu+OmZ31E1QU9uqBWAFGejKIO3XRd+R6z/4=";
});
}
);
};
};
packages = forAllSystems (
system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ self.overlays.default ];
};
in
{
default = pkgs.pinchflat;
pinchflat = pkgs.pinchflat;
}
);
};
}
+37 -4
View File
@@ -23,8 +23,22 @@ defmodule Pinchflat.Media do
Returns [%MediaItem{}, ...]. Returns [%MediaItem{}, ...].
""" """
def list_media_items do def list_media_items(opts \\ []) do
Repo.all(MediaItem) limit = Keyword.get(opts, :limit, 50)
offset = Keyword.get(opts, :offset, 0)
downloaded_only = Keyword.get(opts, :downloaded_only, false)
query =
from(mi in MediaItem, limit: ^limit, offset: ^offset, order_by: [desc: mi.uploaded_at])
query =
if downloaded_only do
from(mi in query, where: not is_nil(mi.media_downloaded_at))
else
query
end
Repo.all(query)
end end
@doc """ @doc """
@@ -72,8 +86,27 @@ defmodule Pinchflat.Media do
Returns [%MediaItem{}, ...]. Returns [%MediaItem{}, ...].
""" """
def list_media_items_for_source(source_id) do def list_media_items_for_source(source_id, opts \\ []) do
Repo.all(from mi in MediaItem, where: mi.source_id == ^source_id) limit = Keyword.get(opts, :limit, 50)
offset = Keyword.get(opts, :offset, 0)
downloaded_only = Keyword.get(opts, :downloaded_only, false)
query =
from(mi in MediaItem,
where: mi.source_id == ^source_id,
limit: ^limit,
offset: ^offset,
order_by: [desc: mi.uploaded_at]
)
query =
if downloaded_only do
from(mi in query, where: not is_nil(mi.media_downloaded_at))
else
query
end
Repo.all(query)
end end
@doc """ @doc """
+3 -1
View File
@@ -17,7 +17,8 @@ defmodule Pinchflat.Settings.Setting do
:youtube_api_key, :youtube_api_key,
:extractor_sleep_interval_seconds, :extractor_sleep_interval_seconds,
:download_throughput_limit, :download_throughput_limit,
:restrict_filenames :restrict_filenames,
:api_token
] ]
@required_fields [ @required_fields [
@@ -40,6 +41,7 @@ defmodule Pinchflat.Settings.Setting do
# This is a string because it accepts values like "100K" or "4.2M" # This is a string because it accepts values like "100K" or "4.2M"
field :download_throughput_limit, :string field :download_throughput_limit, :string
field :restrict_filenames, :boolean, default: false field :restrict_filenames, :boolean, default: false
field :api_token, :string
field :video_codec_preference, :string field :video_codec_preference, :string
field :audio_codec_preference, :string field :audio_codec_preference, :string
+8 -9
View File
@@ -2,10 +2,9 @@ defmodule PinchflatWeb.ApiAuthPlug do
@moduledoc """ @moduledoc """
Authenticates API requests using a Bearer token. Authenticates API requests using a Bearer token.
The token is read from the `PINCHFLAT_API_TOKEN` environment variable. The token is stored in the settings database and managed via the web UI.
If the environment variable is not set, API authentication is disabled A token must be generated before API access is possible — the API is
(useful for development and testing). In production, you should always always authenticated, there is no open-access mode.
set this to a secure value.
Clients should send the token in the Authorization header: Clients should send the token in the Authorization header:
Authorization: Bearer <token> Authorization: Bearer <token>
@@ -17,7 +16,7 @@ defmodule PinchflatWeb.ApiAuthPlug do
def init(opts), do: opts def init(opts), do: opts
def call(conn, _opts) do 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 if credential_set?(expected_token) do
case get_req_header(conn, "authorization") do case get_req_header(conn, "authorization") do
@@ -32,8 +31,8 @@ defmodule PinchflatWeb.ApiAuthPlug do
send_unauthorized(conn) send_unauthorized(conn)
end end
else else
# No API token configured — allow access (dev/test mode) # No API token has been generated yet — deny all access
conn send_unauthorized(conn, "API token not configured. Generate one in Settings.")
end end
end end
@@ -41,10 +40,10 @@ defmodule PinchflatWeb.ApiAuthPlug do
credential && credential != "" credential && credential != ""
end end
defp send_unauthorized(conn) do defp send_unauthorized(conn, message \\ "Unauthorized") do
conn conn
|> put_resp_content_type("application/json") |> put_resp_content_type("application/json")
|> send_resp(401, ~s({"errors":{"detail":"Unauthorized"}})) |> send_resp(401, ~s({"errors":{"detail":"#{message}"}}))
|> halt() |> halt()
end end
end end
@@ -9,17 +9,20 @@ defmodule PinchflatWeb.Api.V1.ApiMediaItemController do
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media
alias Pinchflat.Media.MediaItem
alias Pinchflat.Downloading.MediaDownloadWorker alias Pinchflat.Downloading.MediaDownloadWorker
def index(conn, params) do def index(conn, params) do
limit = parse_param(params, "limit", 50)
offset = parse_param(params, "offset", 0)
downloaded_only = params["downloaded"] == "true"
media_items = media_items =
case params do case params do
%{"source_id" => source_id} -> %{"source_id" => source_id} ->
Media.list_media_items_for_source(source_id) Media.list_media_items_for_source(source_id, limit: limit, offset: offset, downloaded_only: downloaded_only)
_ -> _ ->
Media.list_media_items() Media.list_media_items(limit: limit, offset: offset, downloaded_only: downloaded_only)
end end
media_items = Repo.preload(media_items, :source) media_items = Repo.preload(media_items, :source)
@@ -94,4 +97,17 @@ defmodule PinchflatWeb.Api.V1.ApiMediaItemController do
end) end)
end) end)
end end
defp parse_param(params, key, default) do
case Map.get(params, key) do
nil ->
default
value ->
case Integer.parse(value) do
{n, _} -> n
:error -> default
end
end
end
end end
@@ -6,7 +6,6 @@ defmodule PinchflatWeb.Api.V1.ApiMediaProfileController do
use PinchflatWeb, :controller use PinchflatWeb, :controller
alias Pinchflat.Profiles alias Pinchflat.Profiles
alias Pinchflat.Profiles.MediaProfile
def index(conn, _params) do def index(conn, _params) do
json(conn, %{data: Profiles.list_media_profiles()}) json(conn, %{data: Profiles.list_media_profiles()})
@@ -9,7 +9,6 @@ defmodule PinchflatWeb.Api.V1.ApiSourceController do
alias Pinchflat.Repo alias Pinchflat.Repo
alias Pinchflat.Sources alias Pinchflat.Sources
alias Pinchflat.Sources.Source
alias Pinchflat.Tasks alias Pinchflat.Tasks
alias Pinchflat.Downloading.DownloadingHelpers alias Pinchflat.Downloading.DownloadingHelpers
alias Pinchflat.SlowIndexing.SlowIndexingHelpers alias Pinchflat.SlowIndexing.SlowIndexingHelpers
@@ -43,6 +42,7 @@ defmodule PinchflatWeb.Api.V1.ApiSourceController do
case Sources.create_source(source_params) do case Sources.create_source(source_params) do
{:ok, source} -> {:ok, source} ->
source = Repo.preload(source, :media_profile) source = Repo.preload(source, :media_profile)
conn conn
|> put_status(:created) |> put_status(:created)
|> json(%{data: source}) |> json(%{data: source})
@@ -15,6 +15,7 @@ defmodule PinchflatWeb.Api.V1.ApiTaskController do
case params do case params do
%{"source_id" => source_id} -> %{"source_id" => source_id} ->
source = Pinchflat.Sources.get_source!(source_id) source = Pinchflat.Sources.get_source!(source_id)
Tasks.list_tasks_for(source) Tasks.list_tasks_for(source)
|> Repo.preload(:job) |> Repo.preload(:job)
@@ -28,6 +28,36 @@ defmodule PinchflatWeb.Settings.SettingController do
render(conn, "app_info.html") render(conn, "app_info.html")
end 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 def download_logs(conn, _params) do
log_path = Application.get_env(:pinchflat, :log_path) log_path = Application.get_env(:pinchflat, :log_path)
@@ -26,6 +26,59 @@
)} )}
</section> </section>
<section class="mt-8">
<h3 class="text-2xl text-black dark:text-white">
API Access
</h3>
<p class="text-sm mt-2 max-w-prose text-bodydark2">
Generate a token to authenticate requests to the <code class="font-mono">/api/v1</code> REST API.
Required for MCP server integration and other programmatic clients.
Send it as <code class="font-mono">Authorization: Bearer &lt;token&gt;</code>.
</p>
<div class="mt-4" x-data="{ revealed: false, copied: false }">
<%= if Settings.get!(:api_token) do %>
<% token = Settings.get!(:api_token) %>
<div class="flex items-center gap-3 flex-wrap">
<code class="font-mono text-sm bg-meta-2 dark:bg-meta-4 px-3 py-2 rounded break-all">
<span x-show="!revealed">{String.slice(token, 0, 12)}••••••••</span>
<span x-show="revealed" x-cloak>{token}</span>
</code>
<button type="button" class="text-sm underline" @click="revealed = !revealed">
<span x-show="!revealed">Show</span>
<span x-show="revealed" x-cloak>Hide</span>
</button>
<button
type="button"
class="text-sm underline"
data-token={token}
@click="navigator.clipboard.writeText($el.dataset.token).then(() => { copied = true; setTimeout(() => copied = false, 2000) })"
>
<span x-show="!copied">Copy</span>
<span x-show="copied" x-cloak>Copied!</span>
</button>
<.link href={~p"/settings/generate_api_token"} method="post">
<.button rounding="rounded-lg" class="bg-warning hover:bg-warning/80">Regenerate Token</.button>
</.link>
<.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</.button>
</.link>
</div>
<% else %>
<p class="text-sm text-danger mb-3">
No API token configured. The API is currently inaccessible.
</p>
<.link href={~p"/settings/generate_api_token"} method="post">
<.button rounding="rounded-lg" class="bg-primary hover:bg-primary/80">Generate API Token</.button>
</.link>
<% end %>
</div>
</section>
<section class="mt-8"> <section class="mt-8">
<section> <section>
<h3 class="text-2xl text-black dark:text-white"> <h3 class="text-2xl text-black dark:text-white">
+2
View File
@@ -52,6 +52,8 @@ defmodule PinchflatWeb.Router do
resources "/search", Searches.SearchController, only: [:show], singleton: true resources "/search", Searches.SearchController, only: [:show], singleton: true
resources "/settings", Settings.SettingController, only: [:show, :update], 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 "/app_info", Settings.SettingController, :app_info
get "/download_logs", Settings.SettingController, :download_logs get "/download_logs", Settings.SettingController, :download_logs
@@ -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
+16 -6
View File
@@ -2,24 +2,34 @@ defmodule PinchflatWeb.ApiAuthPlugTest do
use PinchflatWeb.ConnCase use PinchflatWeb.ConnCase
import Pinchflat.ProfilesFixtures import Pinchflat.ProfilesFixtures
alias Pinchflat.Settings
setup do setup do
# Ensure api_token is not set by default in tests # Ensure api_token is cleared in DB for each test
Application.put_env(:pinchflat, :api_token, nil) Settings.set(api_token: nil)
:ok :ok
end end
describe "when no api_token is configured" do 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") 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
end end
describe "when api_token is configured" do describe "when api_token is configured" do
setup do setup do
Application.put_env(:pinchflat, :api_token, "secret-token-123") Settings.set(api_token: "secret-token-123")
on_exit(fn -> Application.put_env(:pinchflat, :api_token, nil) end)
:ok :ok
end end
@@ -1,6 +1,16 @@
defmodule PinchflatWeb.Api.V1.ApiMediaItemControllerTest do defmodule PinchflatWeb.Api.V1.ApiMediaItemControllerTest do
use PinchflatWeb.ConnCase 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.MediaFixtures
import Pinchflat.SourcesFixtures import Pinchflat.SourcesFixtures
import Pinchflat.ProfilesFixtures import Pinchflat.ProfilesFixtures
@@ -1,6 +1,16 @@
defmodule PinchflatWeb.Api.V1.ApiMediaProfileControllerTest do defmodule PinchflatWeb.Api.V1.ApiMediaProfileControllerTest do
use PinchflatWeb.ConnCase 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 import Pinchflat.ProfilesFixtures
describe "GET /api/v1/media_profiles" do describe "GET /api/v1/media_profiles" do
@@ -28,7 +38,10 @@ defmodule PinchflatWeb.Api.V1.ApiMediaProfileControllerTest do
describe "POST /api/v1/media_profiles" do describe "POST /api/v1/media_profiles" do
test "creates a media profile and returns 201", %{conn: conn} do test "creates a media profile and returns 201", %{conn: conn} do
conn = post(conn, ~p"/api/v1/media_profiles", media_profile: %{name: "Test Profile", output_path_template: "{{title}}.{{ext}}"}) conn =
post(conn, ~p"/api/v1/media_profiles",
media_profile: %{name: "Test Profile", output_path_template: "{{title}}.{{ext}}"}
)
assert %{status: 201} = conn assert %{status: 201} = conn
assert json_response(conn, 201)["data"]["name"] == "Test Profile" assert json_response(conn, 201)["data"]["name"] == "Test Profile"
@@ -1,6 +1,16 @@
defmodule PinchflatWeb.Api.V1.ApiSettingControllerTest do defmodule PinchflatWeb.Api.V1.ApiSettingControllerTest do
use PinchflatWeb.ConnCase 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 import Pinchflat.ProfilesFixtures
alias Pinchflat.Settings alias Pinchflat.Settings
@@ -1,6 +1,16 @@
defmodule PinchflatWeb.Api.V1.ApiSourceControllerTest do defmodule PinchflatWeb.Api.V1.ApiSourceControllerTest do
use PinchflatWeb.ConnCase 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.MediaFixtures
import Pinchflat.SourcesFixtures import Pinchflat.SourcesFixtures
import Pinchflat.ProfilesFixtures import Pinchflat.ProfilesFixtures
@@ -1,6 +1,16 @@
defmodule PinchflatWeb.Api.V1.ApiTaskControllerTest do defmodule PinchflatWeb.Api.V1.ApiTaskControllerTest do
use PinchflatWeb.ConnCase 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.SourcesFixtures
import Pinchflat.MediaFixtures import Pinchflat.MediaFixtures