Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions .github/workflows/codeql-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ on:
- 'backend/**'
- 'main.py'
- 'requirements.txt'
- 'requirements-*.txt'
- 'pyproject.toml'
- 'Makefile'
- 'deploy/docker/compose.yaml'
- 'frontend/package-lock.json'
- 'deploy/docker/Dockerfile'
- '.dockerignore'
Expand All @@ -32,6 +36,10 @@ on:
- 'backend/**'
- 'main.py'
- 'requirements.txt'
- 'requirements-*.txt'
- 'pyproject.toml'
- 'Makefile'
- 'deploy/docker/compose.yaml'
- 'frontend/package-lock.json'
- 'deploy/docker/Dockerfile'
- '.dockerignore'
Expand Down Expand Up @@ -165,7 +173,8 @@ jobs:
# named after a develop-only extension may exist anywhere in the tree.
branch-isolation-guard:
name: Branch Isolation Guard
if: github.ref == 'refs/heads/main' || github.base_ref == 'main'
# Always runs (no job-level if) so it stays a satisfiable needs: dependency for
# the docker jobs on every ref; the step below no-ops on non-main-bound refs.
runs-on: ubuntu-latest

steps:
Expand All @@ -175,6 +184,11 @@ jobs:
- name: Fail if develop-only extension code is present
run: |
set -eu
# Only main-bound refs must be extension-free; pass on any other ref.
if [ "${GITHUB_REF}" != "refs/heads/main" ] && [ "${GITHUB_BASE_REF:-}" != "main" ]; then
echo "Not a main-bound ref — branch-isolation check not applicable."
exit 0
fi
leaks=$(git ls-files -- '*cl2k*' \
'backend/extensions/*' ':!backend/extensions/__init__.py' \
'frontend/src/extensions/*' ':!frontend/src/extensions/index.js')
Expand All @@ -189,7 +203,7 @@ jobs:
docker-validate:
name: Docker Validate (PR)
if: github.event_name == 'pull_request'
needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint]
needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint, branch-isolation-guard]
runs-on: ubuntu-latest

steps:
Expand Down Expand Up @@ -226,7 +240,7 @@ jobs:
docker-push:
name: Docker Build & Push
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint]
needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint, branch-isolation-guard]
runs-on: ubuntu-latest
# Serialize with the release-please docker-version job so concurrent pushes
# to the same :latest tag don't race on GHCR auth. cancel-in-progress is
Expand Down Expand Up @@ -289,7 +303,7 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}

notify-failure:
needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint, docker-push]
needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint, branch-isolation-guard, docker-push]
if: failure() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
uses: chodeus/chodeus-ops/.github/workflows/notify-discord.yml@44b9463f55b6d28ca72a126bdc3a89ce6b472166 # main
with:
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/repo-events.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ on:
discussion:
types: [created]

permissions:
contents: read

jobs:
notify:
uses: chodeus/chodeus-ops/.github/workflows/repo-events.yml@44b9463f55b6d28ca72a126bdc3a89ce6b472166 # main
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/stale.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ on:
- cron: '0 5 * * *' # daily
workflow_dispatch:

permissions:
issues: write
pull-requests: write

jobs:
stale:
uses: chodeus/chodeus-ops/.github/workflows/stale-issues.yml@44b9463f55b6d28ca72a126bdc3a89ce6b472166 # main
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "2.36.0"
".": "2.38.0"
}
75 changes: 75 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,81 @@

All notable changes to CHUB are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [2.38.0](https://github.com/chodeus/chub/compare/v2.37.0...v2.38.0) (2026-07-04)


### Features

* **extensions:** add stream_prefixes() hook for extension stream routes ([7ce6841](https://github.com/chodeus/chub/commit/7ce684101687a3060d68b1938a98ffbc9d86078d))


### Bug Fixes

* LIKE escaping, optimize clobber-guard, transient-cache, 0-is-falsy, logout cache, CI perms ([01237f6](https://github.com/chodeus/chub/commit/01237f6adbcffc6381d97fca45ff2a76256742c9))
* **plex_metadata:** don't cache a bloat scan when the Plex DB copy failed ([0518256](https://github.com/chodeus/chub/commit/0518256751ebf691571bf35fc433b3890f7b7647))

## [2.37.0](https://github.com/chodeus/chub/compare/v2.36.0...v2.37.0) (2026-07-01)


### Features

* **auth:** short-lived scoped stream tokens for image/SSE URLs ([1b5e8e4](https://github.com/chodeus/chub/commit/1b5e8e46daafad2d349d0b9b1b65cebce7eba5fd))
* **fields:** add generic action_button settings field type ([b4ec1e2](https://github.com/chodeus/chub/commit/b4ec1e2c403fa9f12f156493551f35f037609ebf))
* **gdrive:** confirm drive removal and optionally delete local folder + cache rows ([5182971](https://github.com/chodeus/chub/commit/5182971a3e4083fa683e56d349598ab51611ef5c))
* **instances:** support renaming an instance end to end ([49c65ed](https://github.com/chodeus/chub/commit/49c65ed3d9e7b60230e125dba6f70d90445a200b))
* **posters:** unified, readable CL2K/MM2K provenance stamp ([9ff36a6](https://github.com/chodeus/chub/commit/9ff36a6aa622460a5da446c07c3ff56f795b570c))
* **stats:** count Sonarr at the episode level (shows/seasons as context) ([00044c8](https://github.com/chodeus/chub/commit/00044c8213653051e64f40e1097900c382b5782b))
* **stats:** order Artist above Album in By type ([dc9d94e](https://github.com/chodeus/chub/commit/dc9d94e819fcd8bbbb92b1e6da2b411cd1c0ecef))
* **stats:** pair By type with By instance, consistent card borders ([df45aa7](https://github.com/chodeus/chub/commit/df45aa7d29318618a30fe8ea1bcbd418bc39f6f2))
* **stats:** regroup Library Statistics by synced instance ([e1853ac](https://github.com/chodeus/chub/commit/e1853ac2bbbb8a7f4b57a59c1c89319c3d2791a0))
* **stats:** show Sonarr Series AND Episodes as separate cards ([ac3fa0e](https://github.com/chodeus/chub/commit/ac3fa0e74ef3a70e8f8380f885f02ba24da7104d))
* **sync:** background instance reconciliation from the Instances page ([0618712](https://github.com/chodeus/chub/commit/06187121d53097caf9f05c838b2dbfc06a9c1839))
* **webhooks:** banner when ingest endpoints are unauthenticated ([8177c90](https://github.com/chodeus/chub/commit/8177c900a77ebdee954fd8c1de81a6838c5b04b2))


### Bug Fixes

* **auth:** keep the stream token warm + rebuild image URLs when it lands ([ac0758b](https://github.com/chodeus/chub/commit/ac0758bf92a0e4d27638a8683dc0f277ab86af60))
* backend correctness and reliability fixes ([03191ea](https://github.com/chodeus/chub/commit/03191ead5422f7871ee4d1c2aa86a5a17a18cefa))
* **config:** split a shared-webhook notification destination by event ([1908da5](https://github.com/chodeus/chub/commit/1908da560ef49fbb52fd1617135155dc956ad53f))
* **data-loss:** guard destructive poster/nohl/upload paths ([274588c](https://github.com/chodeus/chub/commit/274588ce7b7d0df4cef43d871bbf0d6c9298dd9d))
* **docker:** download rclone to its checksummed filename so sha256sum -c passes ([10dddfa](https://github.com/chodeus/chub/commit/10dddfad573968b6e160876c3eb530bc5785b1f0))
* **fields:** action_button ignores a stale result after its row changes ([89b3586](https://github.com/chodeus/chub/commit/89b35865286446686a90c6fc68d7d555643b935e))
* **frontend:** cache invalidation, callbacks, and falsy-value bugs ([5219923](https://github.com/chodeus/chub/commit/52199231c1f01a6430a0a7c35caa6a6d1e48d4cd))
* **frontend:** FloatField 0-value, duplicates-resolve cache, instance keys ([583bb7e](https://github.com/chodeus/chub/commit/583bb7ebb090dbc7fbd65a4a6168c30138f25d92))
* **instances:** case-insensitive source on rename + composite UI-state keys ([079bdce](https://github.com/chodeus/chub/commit/079bdce8252738a56783a9a625cc47efd5ea1b8a))
* **instances:** disable redirects on SSRF-validated health/test probes ([184ffd9](https://github.com/chodeus/chub/commit/184ffd97d99dfde95c6c93a24c6a0a00b217478e))
* **instances:** don't clobber stored API key with the redacted placeholder ([3878d05](https://github.com/chodeus/chub/commit/3878d058b7d2e7b993fff812e2cddc7e2b022997))
* **instances:** read sync_schedule from the nested section response ([4d1666e](https://github.com/chodeus/chub/commit/4d1666e93f9131d0c0a75b6348d0d3d056718060))
* **instances:** resolve redacted API key by URL when renaming ([837e23b](https://github.com/chodeus/chub/commit/837e23bc02db20d432f989ac78f89ef4b36248ae))
* **jobs:** collapse a composite trigger to its readable base ([876e854](https://github.com/chodeus/chub/commit/876e854cada3229f99090df15a234a9d05c91cbd))
* **jobs:** humanize the job-type label ([28967b8](https://github.com/chodeus/chub/commit/28967b8129c6b4a7183c5b8d205a575453e13f4f))
* **logger:** redact secrets in tracebacks, yaml OAuth tokens, and the Notifiarr key ([05ef9df](https://github.com/chodeus/chub/commit/05ef9df8eddbe233efe2aab86a459faa3327dffa))
* **modules:** redact secrets in GET /api/modules/{name} ([8dc024c](https://github.com/chodeus/chub/commit/8dc024c4f341debc8d277963f1b983f1e6827c96))
* **notify:** stop redacted placeholder clobbering destination webhooks ([b38a128](https://github.com/chodeus/chub/commit/b38a1289fa17418f0ca24e2ef9aa17c7e12d22e2))
* **schedule:** reject malformed schedule strings at the API ([40e2233](https://github.com/chodeus/chub/commit/40e22334899a2d3fef2fe996f6606d9e3e8039d1))
* **security:** fail closed on config error + warn on unauthenticated webhook ingest ([23336ef](https://github.com/chodeus/chub/commit/23336ef7f488ca3fd991276aeac440950294b019))
* **security:** fail-closed SSRF guard, key-exfil + redirect guards, auth-section write block ([012ce45](https://github.com/chodeus/chub/commit/012ce4563b04fdbcae94482e30a23b7d45accaad))
* **stats:** derive Lidarr has_content from track files for accurate library stats ([28dfaff](https://github.com/chodeus/chub/commit/28dfaff77ac7adb7504d8a5b0a57f5b0b24df433))
* **stats:** exclude unmonitored-artist albums from Lidarr Missing ([a4e6eab](https://github.com/chodeus/chub/commit/a4e6eabf726189efcca898bf229a3b0c1900a645))
* **stats:** normalize instance source case (title-cased source broke labels/order/freshness) ([3b4165e](https://github.com/chodeus/chub/commit/3b4165ee504391a5d9492ef45fbbe1d72a7dd9fb))
* **stats:** normalize media_cache.source at the write boundary ([5236831](https://github.com/chodeus/chub/commit/52368316db4f284708b21f1ae3d8e6c702e7264e))
* **stats:** read the matched_posters_stats key so Top contributors renders ([ae30605](https://github.com/chodeus/chub/commit/ae30605394ebbd262a8fc10af6b8523f0e46fba0))
* **stats:** release-gate Missing + add Upcoming; drop Statistics refresh ([a46a416](https://github.com/chodeus/chub/commit/a46a4160a2b675e5a92222063a89a157a70342dc))
* **unmatched:** move recently-matched provenance stamp to the poster top-left ([3f04e25](https://github.com/chodeus/chub/commit/3f04e258c7b9981808b1e368a4d6b1c9b9da81be))
* **webhooks:** stop clobbering per-season has_content/monitored on upsert ([5ab982c](https://github.com/chodeus/chub/commit/5ab982cfe701ad003f9ca134ba72e56a24014672))
* **worker:** scope startup job reset to the worker's own partition ([982a7b1](https://github.com/chodeus/chub/commit/982a7b15cdd92ef774ec79b275be1b7271e46aa5))


### Performance

* **instances:** drop the live wanted/missing call from the Instances page ([cc5a075](https://github.com/chodeus/chub/commit/cc5a07561db2f1843789d1d845d2a6c091344c12))


### Refactoring

* **instances:** drop the service-type badge from instance rows ([838dab5](https://github.com/chodeus/chub/commit/838dab5976f2ea79ab6671a7a1cead4894bbb189))

## [2.36.0](https://github.com/chodeus/chub/compare/v2.35.0...v2.36.0) (2026-06-28)


Expand Down
5 changes: 3 additions & 2 deletions backend/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
webhooks as webhooks_router,
)
from backend.api.utils import error, get_logger
from backend.extensions import extension_routers
from backend.extensions import extension_routers, extension_stream_prefixes
from backend.util.auth import decode_access_token
from backend.util.config import (
ConfigError,
Expand Down Expand Up @@ -173,7 +173,8 @@ async def dispatch(self, request: Request, call_next):
# valid for GET requests on the stream allowlist — never for mutations or
# general API access, so a leaked image/SSE URL can't do more than that.
if payload.get("scope") == "stream" and (
request.method != "GET" or not path.startswith(STREAM_PATH_PREFIXES)
request.method != "GET"
or not path.startswith(STREAM_PATH_PREFIXES + extension_stream_prefixes())
):
self._log_unauthorized(request, "stream token off stream route", path)
return JSONResponse(
Expand Down
31 changes: 27 additions & 4 deletions backend/api/posters.py
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,17 @@ def _optimize_posters_sync(
dest_path = (
base + target_ext if needs_convert else full_path
)
# Don't clobber a different pre-existing file at the
# converted extension (e.g. a Kometa .jpg beside a
# Plex .png) — leave both, mirror poster_self_heal.
if dest_path != full_path and os.path.exists(dest_path):
os.unlink(tmp.name)
logger.warning(
f"optimize target exists, skipped to avoid "
f"clobber: {dest_path}"
)
skipped += 1
continue
shutil.move(tmp.name, dest_path)
if dest_path != full_path:
if os.path.exists(full_path):
Expand Down Expand Up @@ -2364,9 +2375,13 @@ async def delete_gdrive_local(
# left behind either way.
import shutil

# Delete the CONFIGURED folder (re-resolved), authorized by the membership
# match above — never the raw request path, so a validated request string
# can't reach rmtree. target == req_real by that match.
target = os.path.realpath(matched.location)
folder_removed = False
if os.path.isdir(req_real):
shutil.rmtree(req_real)
if os.path.isdir(target):
shutil.rmtree(target)
folder_removed = True

deleted_rows = db.poster.delete_by_path_prefix(matched.location)
Expand Down Expand Up @@ -3832,10 +3847,18 @@ async def delete_poster(
try:
# Find media items that were matched to this poster by original_file
if full_path:
# Escape LIKE metacharacters so a basename with %/_ can't
# unmatch the wrong media rows (mirror poster_cache).
basename_like = (
os.path.basename(full_path)
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
media_items = (
db.media.execute_query(
"SELECT id, title, instance_name, asset_type, year, season_number FROM media_cache WHERE original_file LIKE ?",
(f"%{os.path.basename(full_path)}%",),
"SELECT id, title, instance_name, asset_type, year, season_number FROM media_cache WHERE original_file LIKE ? ESCAPE '\\'",
(f"%{basename_like}%",),
fetch_all=True,
)
or []
Expand Down
14 changes: 14 additions & 0 deletions backend/extensions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
(consumed by backend/util/config.py).
tables() -> list[TableDefinition]
Extra database tables (consumed by SchemaManager.__init__).
stream_prefixes() -> Iterable[str]
URL path prefixes whose GET routes accept a scope="stream" token
(merged into AuthMiddleware's STREAM_PATH_PREFIXES allowlist).

Hook functions should import their payloads lazily (inside the function
body, not at manifest import time): manifests are imported while core
Expand Down Expand Up @@ -84,3 +87,14 @@ def extension_notification_formatters() -> Dict[str, Any]:
for entry in _collect("notification_formatters"):
merged.update(entry)
return merged


def extension_stream_prefixes() -> Tuple[str, ...]:
"""URL path prefixes on which an extension's GET routes accept a
scope="stream" token, merged into AuthMiddleware's STREAM_PATH_PREFIXES so an
extension can serve <img>/SSE endpoints authenticated by a short-lived stream
token instead of a header. Each manifest's ``stream_prefixes()`` returns an
iterable of str prefixes. Empty on main (no extensions)."""
return tuple(
prefix for prefixes in _collect("stream_prefixes") for prefix in prefixes
)
27 changes: 23 additions & 4 deletions backend/util/database/media_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -872,8 +872,22 @@ def search(
# whose normalized form has the punctuation stripped. Raw
# title LIKE is the case-insensitive fallback for exact
# stored substrings. Same fix as poster_cache.search.
sub = ["normalized_title LIKE ?", "title LIKE ?"]
sub_params: List[Any] = [f"%{normalize_titles(query)}%", f"%{query}%"]
# Escape LIKE metacharacters so a query with %/_ matches literally
# (mirrors poster_cache.delete_by_path_prefix).
norm_esc = (
normalize_titles(query)
.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_")
)
raw_esc = (
query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
)
sub = [
"normalized_title LIKE ? ESCAPE '\\'",
"title LIKE ? ESCAPE '\\'",
]
sub_params: List[Any] = [f"%{norm_esc}%", f"%{raw_esc}%"]
# Also match an id pasted from a filename tag ({tmdb-…}/{tvdb-…}/
# {imdb-tt…}) or a bare IMDb id, so users can search by id.
tmdb, tvdb, imdb = parse_search_id(query)
Expand All @@ -894,9 +908,14 @@ def search(
params.append(asset_type)

if genres:
genre_conditions = ["genre LIKE ?" for _ in genres]
genre_conditions = ["genre LIKE ? ESCAPE '\\'" for _ in genres]
conditions.append(f"({' OR '.join(genre_conditions)})")
params.extend(f"%{g}%" for g in genres)
params.extend(
"%"
+ g.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+ "%"
for g in genres
)

if year_min is not None:
conditions.append("CAST(year AS INTEGER) >= ?")
Expand Down
10 changes: 8 additions & 2 deletions backend/util/database/poster_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,14 @@ def esc(s: str) -> str:
]
sub_params: list = [norm_pat, raw_pat]
else:
sub = ["normalized_title LIKE ?", "title LIKE ?"]
sub_params = [f"%{normalize_titles(query)}%", f"%{query}%"]
sub = [
"normalized_title LIKE ? ESCAPE '\\'",
"title LIKE ? ESCAPE '\\'",
]
sub_params = [
f"%{esc(normalize_titles(query))}%",
f"%{esc(query)}%",
]

tmdb, tvdb, imdb = parse_search_id(query)
if tmdb is not None:
Expand Down
Loading
Loading