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:
push:
@@ -12,38 +12,28 @@ on:
jobs:
build-and-test:
name: Build, Lint, and Test
name: Build and Test
runs-on: ubuntu-latest
if: "! contains(toJSON(github.event.commits.*.message), '[skip ci]')"
env:
COMPOSE_FILE: ./docker-compose.ci.yml
MIX_ENV: test
DOCKER_BUILDKIT: 1
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Pull prebuilt images
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
- name: Build and run Docker image
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
run: |
@@ -53,5 +43,5 @@ jobs:
- name: Run code checks
run: docker compose exec -T phx mix check --no-fix --no-retry
- name: Run API tests
run: docker compose exec -T phx mix test test/pinchflat_web/controllers/api test/pinchflat_web/api_auth_plug_test.exs --trace
- name: Run tests
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_password: "",
expose_feed_endpoints: false,
api_token: "",
file_watcher_poll_interval: 1000,
timezone: "UTC",
base_route_path: "/"
+1 -2
View File
@@ -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))
-4
View File
@@ -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
-5
View File
@@ -5,11 +5,6 @@ services:
dockerfile: ./docker/dev.Dockerfile
environment:
- MIX_ENV=test
volumes:
- '.:/app'
# These lines ensure the deps can be saved as build artifacts for caching
- '/app/deps'
- '/app/_build'
ports:
- '4008:4008'
command: tail -F /dev/null
+12 -17
View File
@@ -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
@@ -30,7 +25,7 @@ All endpoints are under `/api/v1`.
### Sources
| Method | Path | Description |
|--------|------|-------------|
| ------ | --------------------------------------------------- | ------------------------------------ |
| GET | `/api/v1/sources` | List all sources |
| GET | `/api/v1/sources/:id` | Get a source with its pending tasks |
| POST | `/api/v1/sources` | Create a new source |
@@ -45,7 +40,7 @@ All endpoints are under `/api/v1`.
### Media Items
| Method | Path | Description |
|--------|------|-------------|
| ------ | --------------------------------------------- | ----------------------------------------------------- |
| 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/:id` | Get a media item |
@@ -56,7 +51,7 @@ All endpoints are under `/api/v1`.
### Media Profiles
| Method | Path | Description |
|--------|------|-------------|
| ------ | ---------------------------- | ----------------------- |
| GET | `/api/v1/media_profiles` | List all media profiles |
| GET | `/api/v1/media_profiles/:id` | Get a media profile |
| POST | `/api/v1/media_profiles` | Create a media profile |
@@ -66,7 +61,7 @@ All endpoints are under `/api/v1`.
### Settings
| Method | Path | Description |
|--------|------|-------------|
| ------ | ------------------ | ----------------------------------------- |
| GET | `/api/v1/settings` | Get settings (route_token excluded) |
| PUT | `/api/v1/settings` | Update settings |
| GET | `/api/v1/app_info` | Get app info (version, environment, etc.) |
@@ -74,7 +69,7 @@ All endpoints are under `/api/v1`.
### Tasks
| Method | Path | Description |
|--------|------|-------------|
| ------ | ------------------- | ----------------------------------------------- |
| GET | `/api/v1/tasks` | List all tasks (optional `?source_id=X` filter) |
| GET | `/api/v1/tasks/:id` | Get a task |
@@ -89,7 +84,7 @@ All responses are JSON. Successful responses use:
Error responses use:
```json
{"errors": {"field": "error message"}}
{ "errors": { "field": "error message" } }
```
## Example: Create a Source
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{}, ...].
"""
def list_media_items do
Repo.all(MediaItem)
def list_media_items(opts \\ []) do
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
@doc """
@@ -72,8 +86,27 @@ defmodule Pinchflat.Media do
Returns [%MediaItem{}, ...].
"""
def list_media_items_for_source(source_id) do
Repo.all(from mi in MediaItem, where: mi.source_id == ^source_id)
def list_media_items_for_source(source_id, opts \\ []) do
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
@doc """
+3 -1
View File
@@ -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
+8 -9
View File
@@ -2,10 +2,9 @@ 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 <token>
@@ -17,7 +16,7 @@ defmodule PinchflatWeb.ApiAuthPlug do
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
@@ -32,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
@@ -41,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
@@ -9,17 +9,20 @@ defmodule PinchflatWeb.Api.V1.ApiMediaItemController do
alias Pinchflat.Repo
alias Pinchflat.Media
alias Pinchflat.Media.MediaItem
alias Pinchflat.Downloading.MediaDownloadWorker
def index(conn, params) do
limit = parse_param(params, "limit", 50)
offset = parse_param(params, "offset", 0)
downloaded_only = params["downloaded"] == "true"
media_items =
case params do
%{"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
media_items = Repo.preload(media_items, :source)
@@ -94,4 +97,17 @@ defmodule PinchflatWeb.Api.V1.ApiMediaItemController do
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
@@ -6,7 +6,6 @@ defmodule PinchflatWeb.Api.V1.ApiMediaProfileController do
use PinchflatWeb, :controller
alias Pinchflat.Profiles
alias Pinchflat.Profiles.MediaProfile
def index(conn, _params) do
json(conn, %{data: Profiles.list_media_profiles()})
@@ -9,7 +9,6 @@ defmodule PinchflatWeb.Api.V1.ApiSourceController do
alias Pinchflat.Repo
alias Pinchflat.Sources
alias Pinchflat.Sources.Source
alias Pinchflat.Tasks
alias Pinchflat.Downloading.DownloadingHelpers
alias Pinchflat.SlowIndexing.SlowIndexingHelpers
@@ -43,6 +42,7 @@ defmodule PinchflatWeb.Api.V1.ApiSourceController do
case Sources.create_source(source_params) do
{:ok, source} ->
source = Repo.preload(source, :media_profile)
conn
|> put_status(:created)
|> json(%{data: source})
@@ -15,6 +15,7 @@ defmodule PinchflatWeb.Api.V1.ApiTaskController do
case params do
%{"source_id" => source_id} ->
source = Pinchflat.Sources.get_source!(source_id)
Tasks.list_tasks_for(source)
|> Repo.preload(:job)
@@ -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)
@@ -26,6 +26,59 @@
)}
</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>
<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 "/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
@@ -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
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
@@ -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
@@ -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
@@ -28,7 +38,10 @@ defmodule PinchflatWeb.Api.V1.ApiMediaProfileControllerTest do
describe "POST /api/v1/media_profiles" 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 json_response(conn, 201)["data"]["name"] == "Test Profile"
@@ -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
@@ -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
@@ -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