diff --git a/CLAUDE.md b/CLAUDE.md index 6d9023439..b75f75632 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ Django app served by Granian (ASGI). Key subsystems: Bookmark, Identifier. SQLite with WAL mode. - **`views/`** — DRF ViewSets organized by feature: `browser/` (comic listings), `reader/` (page serving), `admin/` (CRUD), `opds/` (syndication). -- **`urls/`** — API at `/api/v3/`. Sub-routers: `/auth/`, `/c/` (reader), +- **`urls/`** — API at `/api/v4/`. Sub-routers: `/auth/`, `/c/` (reader), `//` (browser), `/admin/`. - **`serializers/`** — DRF serializers for browser, reader, and admin responses. - **`librarian/`** — Multiprocessing background daemon with dedicated threads: @@ -66,7 +66,7 @@ Vue 3 + Vite + Vuetify 4 SPA. - **`src/stores/`** — Pinia stores: `browser`, `reader`, `auth`, `metadata`, `socket`, `admin`. -- **`src/api/v3/`** — HTTP client (xior) with automatic CSRF token injection. +- **`src/api/v4/`** — HTTP client (xior) with automatic CSRF token injection. - **`src/components/`** — Organized by view: `browser/`, `reader/`, `admin/`, `metadata/`, `settings/`. - **`src/plugins/`** — Vue Router, Vuetify, drag-scroll. diff --git a/NEWS.md b/NEWS.md index 96adfa131..e89b6ba48 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,19 @@ width: 128px; border-radius: 128px; " /> +## v2.2.4 + +- Features + - Metron Cloud online tagging uses an API key, generated on your + metron.cloud account page (comicbox 4.7.1). Saved usernames and passwords + keep working until you save a key, which replaces them. + - The custom URL fields for Metron Cloud and Comic Vine are removed. The + Metron one never did anything — mokkari hardcodes the metron.cloud + endpoint. Any saved URLs are discarded on upgrade. + - The interactive Swagger API docs are back, at `/api/v4/`. They went away + in v2.0.0 with the v3 API; only the raw OpenAPI schema at `/api/v4/schema` + remained. Admin login required, as before. + ## v2.2.3 - Features diff --git a/codex/failed_login_log.py b/codex/failed_login_log.py index da5f0202d..1811cc1db 100644 --- a/codex/failed_login_log.py +++ b/codex/failed_login_log.py @@ -8,7 +8,7 @@ separate file. The main stdout / codex.log sinks apply the inverse filter (:func:`not_failed_login_filter`) so the IP-bearing line **only** lands in the dedicated log — Django's own request logger still records the bare -``"Unauthorized: /api/v3/auth/login/"`` at WARNING so the failure is visible +``"Unauthorized: /api/v4/auth/login/"`` at WARNING so the failure is visible in the main log, just without the client IP. Concentrating IPs in one place makes the privacy story easier to reason about (one file to chmod, one file to forward to a SIEM, one file to retain on a different schedule). diff --git a/codex/librarian/onlinetag/credential_validator.py b/codex/librarian/onlinetag/credential_validator.py index d9bf13973..4b539289c 100644 --- a/codex/librarian/onlinetag/credential_validator.py +++ b/codex/librarian/onlinetag/credential_validator.py @@ -86,14 +86,18 @@ def _extract_rate_limits(status: RateLimitStatus) -> RateLimitInfo | None: def _validate_metron(creds: OnlineCredentials) -> ValidationResult: - if not (creds.metron_user and creds.metron_password): - return ValidationResult(ok=False, error="Username and password required.") + if not (creds.metron_key or (creds.metron_user and creds.metron_password)): + return ValidationResult(ok=False, error="API key required.") from mokkari.exceptions import ApiError, AuthenticationError from mokkari.session import Session + # ``or None`` is load bearing: mokkari sends a Bearer header whenever + # api_token is not None, so an empty string would defeat the legacy + # username & password fallback. session = Session( username=creds.metron_user, passwd=creds.metron_password, + api_token=creds.metron_key or None, cache=None, user_agent="codex-credential-check", ) @@ -133,8 +137,6 @@ def _validate_comicvine(creds: OnlineCredentials) -> ValidationResult: "cache_expiry": DO_NOT_CACHE, "ratelimit_path": tmp_path / "ratelimits.sqlite", } - if creds.comicvine_url: - kwargs["base_url"] = creds.comicvine_url cv = Comicvine(**kwargs) try: cv.list_publishers(params={"limit": "1"}, max_results=1) diff --git a/codex/librarian/onlinetag/explicit_id.py b/codex/librarian/onlinetag/explicit_id.py index 9fd00676b..0d3d30f87 100644 --- a/codex/librarian/onlinetag/explicit_id.py +++ b/codex/librarian/onlinetag/explicit_id.py @@ -42,14 +42,11 @@ def _build_auth_source( """Map codex credentials onto comicbox's per-source auth for one source.""" if source == "metron": return OnlineSourceCredentials( + key=credentials.metron_key, user=credentials.metron_user, password=credentials.metron_password, - url=credentials.metron_url, ) - return OnlineSourceCredentials( - key=credentials.comicvine_key, - url=credentials.comicvine_url, - ) + return OnlineSourceCredentials(key=credentials.comicvine_key) def build_explicit_id_config( diff --git a/codex/librarian/onlinetag/session_manager.py b/codex/librarian/onlinetag/session_manager.py index a8a8c92ed..7830a4bb5 100644 --- a/codex/librarian/onlinetag/session_manager.py +++ b/codex/librarian/onlinetag/session_manager.py @@ -105,21 +105,27 @@ def _build_credentials(self) -> OnlineCredentials | None: defaults = ComicboxTaggingDefaults.objects.get(pk=1) except ComicboxTaggingDefaults.DoesNotExist: return None - if not defaults.metron_user and not defaults.comicvine_key: + if ( + not defaults.metron_key + and not defaults.metron_user + and not defaults.comicvine_key + ): return None return OnlineCredentials( + metron_key=defaults.metron_key or "", metron_user=defaults.metron_user or "", metron_password=defaults.metron_password or "", - metron_url=defaults.metron_url or "", comicvine_key=defaults.comicvine_key or "", - comicvine_url=defaults.comicvine_url or "", ) @staticmethod def _source_has_credentials(credentials: OnlineCredentials, source: str) -> bool: """Whether ``credentials`` actually carries auth for ``source``.""" if source == "metron": - return bool(credentials.metron_user and credentials.metron_password) + return bool( + credentials.metron_key + or (credentials.metron_user and credentials.metron_password) + ) if source == "comicvine": return bool(credentials.comicvine_key) return False diff --git a/codex/librarian/telemeter/admin_stats.py b/codex/librarian/telemeter/admin_stats.py index 51482fd2e..e4a17b2b5 100644 --- a/codex/librarian/telemeter/admin_stats.py +++ b/codex/librarian/telemeter/admin_stats.py @@ -143,7 +143,7 @@ def _default_sources(defaults: ComicboxTaggingDefaults) -> dict[str, int]: def get_tagging_stats() -> dict[str, Any]: - """Report the online tagging defaults. Never the credentials or urls.""" + """Report the online tagging defaults. Never the credentials.""" defaults = ComicboxTaggingDefaults.objects.first() if not defaults: return {} @@ -157,11 +157,9 @@ def get_tagging_stats() -> dict[str, Any]: "default_sources": _default_sources(defaults), "default_format_count": len(formats) if isinstance(formats, list) else 0, "has_metron_credentials": bool( - defaults.metron_user and defaults.metron_password + defaults.metron_key or (defaults.metron_user and defaults.metron_password) ), "has_comicvine_credentials": bool(defaults.comicvine_key), - "metron_url_set": bool(defaults.metron_url), - "comicvine_url_set": bool(defaults.comicvine_url), } diff --git a/codex/migrations/0050_comicboxtaggingdefaults_metron_key.py b/codex/migrations/0050_comicboxtaggingdefaults_metron_key.py new file mode 100644 index 000000000..d6c8d1507 --- /dev/null +++ b/codex/migrations/0050_comicboxtaggingdefaults_metron_key.py @@ -0,0 +1,23 @@ +"""Generated by Django 6.0.6 on 2026-07-28 12:00.""" + +from django.db import migrations + +import codex.models.fields + + +class Migration(migrations.Migration): + """Add metron_key API token to ComicboxTaggingDefaults.""" + + dependencies = [ + ("codex", "0049_reprints"), + ] + + operations = [ + migrations.AddField( + model_name="comicboxtaggingdefaults", + name="metron_key", + field=codex.models.fields.EncryptedCharField( + blank=True, default="", max_length=512 + ), + ), + ] diff --git a/codex/migrations/0051_remove_comicboxtaggingdefaults_urls.py b/codex/migrations/0051_remove_comicboxtaggingdefaults_urls.py new file mode 100644 index 000000000..aceb060d3 --- /dev/null +++ b/codex/migrations/0051_remove_comicboxtaggingdefaults_urls.py @@ -0,0 +1,30 @@ +"""Generated by Django 6.0.7 on 2026-07-29 06:10.""" + +from django.db import migrations + + +class Migration(migrations.Migration): + """ + Remove the custom Metron & Comic Vine URL overrides. + + The Metron override was always a no-op: mokkari hardcodes its API + endpoint, and comicbox's metron source warns that the url is ignored. + The Comic Vine override worked but serves no purpose for this app. + comicbox still defines the fields on its credentials dataclass; codex + just stops passing them. + """ + + dependencies = [ + ("codex", "0050_comicboxtaggingdefaults_metron_key"), + ] + + operations = [ + migrations.RemoveField( + model_name="comicboxtaggingdefaults", + name="comicvine_url", + ), + migrations.RemoveField( + model_name="comicboxtaggingdefaults", + name="metron_url", + ), + ] diff --git a/codex/models/admin.py b/codex/models/admin.py index ca05f4363..3e8c4d0ae 100644 --- a/codex/models/admin.py +++ b/codex/models/admin.py @@ -116,11 +116,14 @@ class PromptsModeChoices(TextChoices): # of enabled sources. Admin default; overridable per scan. merge_all_sources = BooleanField(default=False) + # metron_user & metron_password are legacy. Metron authenticates with an + # API token now, and the admin UI only accepts one. Existing logins keep + # working (metron.cloud and comicbox still accept them) until an API key + # is saved, which clears them. mokkari prefers the token when both exist. + metron_key = EncryptedCharField() metron_user = EncryptedCharField() metron_password = EncryptedCharField() - metron_url = URLField(max_length=256, blank=True, default="") comicvine_key = EncryptedCharField() - comicvine_url = URLField(max_length=256, blank=True, default="") # Active session id + pending prompts used to live here. They are # transient operational state — they only matter while a tagging diff --git a/codex/serializers/admin/stats.py b/codex/serializers/admin/stats.py index 44139664b..a34f4075e 100644 --- a/codex/serializers/admin/stats.py +++ b/codex/serializers/admin/stats.py @@ -171,7 +171,7 @@ class StatsTaggingSerializer(Serializer): """ Online Tagging Defaults. - Credentials and service urls report only whether they are configured. + Credentials report only whether they are configured. """ default_match_mode = CharField(required=False, read_only=True) @@ -183,8 +183,6 @@ class StatsTaggingSerializer(Serializer): default_format_count = IntegerField(required=False, read_only=True) has_metron_credentials = BooleanField(required=False, read_only=True) has_comicvine_credentials = BooleanField(required=False, read_only=True) - metron_url_set = BooleanField(required=False, read_only=True) - comicvine_url_set = BooleanField(required=False, read_only=True) class StatsAuthSerializer(Serializer): diff --git a/codex/serializers/admin/tagging.py b/codex/serializers/admin/tagging.py index 122c9aca6..22da2edbb 100644 --- a/codex/serializers/admin/tagging.py +++ b/codex/serializers/admin/tagging.py @@ -1,5 +1,7 @@ """Comicbox tagging serializers.""" +from typing import override + from comicbox.formats.base.online import SOURCE_NAMES from rest_framework.fields import SerializerMethodField from rest_framework.serializers import ( @@ -102,11 +104,8 @@ class TaggingValidateRequestSerializer(Serializer): source = ChoiceField( choices=tuple(sorted(KNOWN_SOURCES)), required=False, allow_blank=True ) - metron_user = CharField(required=False, allow_blank=True) - metron_password = CharField(required=False, allow_blank=True) - metron_url = CharField(required=False, allow_blank=True) + metron_key = CharField(required=False, allow_blank=True) comicvine_key = CharField(required=False, allow_blank=True) - comicvine_url = CharField(required=False, allow_blank=True) class TaggingRateLimitWindowSerializer(Serializer): @@ -142,26 +141,19 @@ class TaggingValidateResponseSerializer(Serializer): class ComicboxTaggingDefaultsSerializer(BaseModelSerializer): """Serializer for ComicboxTaggingDefaults singleton.""" - metron_user = CharField(write_only=True, required=False, allow_blank=True) - metron_password = CharField(write_only=True, required=False, allow_blank=True) + metron_key = CharField(write_only=True, required=False, allow_blank=True) comicvine_key = CharField(write_only=True, required=False, allow_blank=True) - metron_user_set = SerializerMethodField() - metron_password_set = SerializerMethodField() + metron_key_set = SerializerMethodField() comicvine_key_set = SerializerMethodField() has_metron_credentials = SerializerMethodField() has_comicvine_credentials = SerializerMethodField() @staticmethod - def get_metron_user_set(obj) -> bool: - """Whether a Metron username has been configured.""" - return bool(obj.metron_user) - - @staticmethod - def get_metron_password_set(obj) -> bool: - """Whether a Metron password has been configured.""" - return bool(obj.metron_password) + def get_metron_key_set(obj) -> bool: + """Whether a Metron API key has been configured.""" + return bool(obj.metron_key) @staticmethod def get_comicvine_key_set(obj) -> bool: @@ -170,8 +162,12 @@ def get_comicvine_key_set(obj) -> bool: @staticmethod def get_has_metron_credentials(obj) -> bool: - """Whether both Metron username and password are set.""" - return bool(obj.metron_user and obj.metron_password) + """ + Whether Metron can authenticate: an API key or a legacy login. + + Mirrors comicbox's ``MetronOnlineSource.is_configured``. + """ + return bool(obj.metron_key or (obj.metron_user and obj.metron_password)) @staticmethod def get_has_comicvine_credentials(obj) -> bool: @@ -183,6 +179,20 @@ def validate_default_sources(value: list) -> list: """Require known source names; preserve the priority order.""" return _validate_ordered_sources(value) + @override + def update(self, instance, validated_data): + """ + Retire the legacy username & password whenever the API key is written. + + The key is the only Metron credential the UI offers now, so saving one + (or clearing it) drops the old login rather than leaving a stale + fallback that mokkari would silently ignore anyway. + """ + if "metron_key" in validated_data: + validated_data["metron_user"] = "" + validated_data["metron_password"] = "" + return super().update(instance, validated_data) + class Meta(BaseModelSerializer.Meta): """Specify model and fields.""" @@ -195,20 +205,15 @@ class Meta(BaseModelSerializer.Meta): "default_prompts_mode", "default_sources", "merge_all_sources", - "metron_user", - "metron_password", - "metron_url", + "metron_key", "comicvine_key", - "comicvine_url", - "metron_user_set", - "metron_password_set", + "metron_key_set", "comicvine_key_set", "has_metron_credentials", "has_comicvine_credentials", ) read_only_fields = ( - "metron_user_set", - "metron_password_set", + "metron_key_set", "comicvine_key_set", "has_metron_credentials", "has_comicvine_credentials", diff --git a/codex/settings/__init__.py b/codex/settings/__init__.py index 1b161d39f..b847ac4e1 100644 --- a/codex/settings/__init__.py +++ b/codex/settings/__init__.py @@ -15,7 +15,7 @@ from os import cpu_count, environ from pathlib import Path from types import MappingProxyType -from typing import NamedTuple +from typing import Final, NamedTuple from comicbox.config import get_config from comicbox.config.settings import ComicboxSettings @@ -397,21 +397,22 @@ class FeatureFlags(NamedTuple): ) # drf-spectacular's Swagger UI pulls assets from jsdelivr. -_SWAGGER_SECURE_CSP: Mapping[str, tuple[str, ...]] = MappingProxyType( +# ``script-src-elem`` must repeat the bundles because the pdfs-dist +# overlay declares that directive — without it pdfs-dist masks the +# ``script-src`` fallback and every CDN