P2 minor set: password off argv, configurable share gate, POST logout, log path check

- Navidrome password is now passed to curl via stdin (--data-urlencode "p@-")
  in navidrome-scan.sh and pipeline-status.sh, so it no longer appears in
  ps/proc. Verified the query sent is identical and a live scan still triggers.
- MIN_ARTIST_DIRS (the share-health gate) is now a setting, threaded through to
  the pipeline env, so a user with a small library can lower it instead of the
  scan/sync being permanently blocked by the hardcoded 500.
- /auth/logout is now POST-only (with a nav form + aria-label), so a drive-by
  GET can't log the user out; enforced allowed_email already landed separately.
- view_log now confirms the run's log_path resolves under the logs dir before
  serving it (defense in depth).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
andrew
2026-07-10 15:50:06 -06:00
parent 2641a1b874
commit 2c86d7973f
9 changed files with 40 additions and 11 deletions
+1 -1
View File
@@ -55,7 +55,7 @@ async def auth_callback(request: Request):
return RedirectResponse(url="/")
@router.get("/logout")
@router.post("/logout")
async def logout(request: Request):
request.session.clear()
end_session_endpoint = None
+8 -1
View File
@@ -99,7 +99,14 @@ async def view_log(run_id: int, user: dict = Depends(require_auth), db=Depends(g
raise HTTPException(404, "no such run/log")
from pathlib import Path
path = Path(run.log_path)
from app.settings import settings
# Containment check: only ever serve files from under the logs dir. Paths
# are app-generated so this is defense in depth against a malformed row.
logs_dir = settings.logs_dir.resolve()
path = Path(run.log_path).resolve()
if logs_dir != path and logs_dir not in path.parents:
raise HTTPException(404, "no such run/log")
if not path.exists():
raise HTTPException(404, "log file no longer exists")
return path.read_text(errors="replace")
+1
View File
@@ -42,6 +42,7 @@ def _subprocess_env() -> dict:
env["MUSIC_DATA_DIR"] = str(settings.music_data_dir)
env["PIPELINE_DIR"] = str(settings.pipeline_dir)
env["BEETSDIR"] = str(settings.beets_dir)
env["MIN_ARTIST_DIRS"] = str(settings.min_artist_dirs)
return env
+6
View File
@@ -48,6 +48,12 @@ class Settings(BaseSettings):
validation_alias=AliasChoices("TZ", "ALEMBIC_TIMEZONE", "TIMEZONE"),
)
# Share-health gate: the Navidrome scan and Bandcamp sync abort if the
# library has fewer than this many artist folders, to catch a half-mounted
# or empty share before it does damage. The default suits a large library;
# set it lower (via env MIN_ARTIST_DIRS) if yours is small.
min_artist_dirs: int = 500
@property
def beets_dir(self) -> Path:
return self.alembic_config_dir / "beets"
+12 -2
View File
@@ -219,11 +219,21 @@ a:hover { color: var(--iris-pink); text-decoration: underline; }
background: var(--status-success);
box-shadow: 0 0 8px var(--status-success);
}
.user-chip a.logout {
.user-chip a.logout,
.user-chip button.logout {
color: var(--muted-2);
font-size: 0.95rem;
}
.user-chip a.logout:hover { color: var(--status-danger); text-decoration: none; }
.user-chip button.logout {
background: none;
border: none;
padding: 0;
cursor: pointer;
font-family: inherit;
line-height: 1;
}
.user-chip a.logout:hover,
.user-chip button.logout:hover { color: var(--status-danger); text-decoration: none; }
/* ---- layout ---- */
+3 -1
View File
@@ -51,7 +51,9 @@
<div class="user-chip">
<span class="user-dot"></span>
<span>{{ user.email or user.name }}</span>
<a class="logout" href="/auth/logout" title="Log out">&#9211;</a>
<form method="post" action="/auth/logout" style="display:inline; margin:0;">
<button type="submit" class="logout" title="Log out" aria-label="Log out">&#9211;</button>
</form>
</div>
{% endif %}
</div>
+5 -3
View File
@@ -21,7 +21,7 @@ NAVIDROME_ENV="$ALEMBIC_CONFIG_DIR/pipeline/navidrome/admin.env"
LIB="$MUSIC_DATA_DIR/Library"
CANARY="$LIB/.navidrome-canary"
MIN_ARTIST_DIRS=500 # library has ~1500; 500 is "clearly not wiped"
MIN_ARTIST_DIRS="${MIN_ARTIST_DIRS:-500}" # override via env for a small library
ND="${ND_BASE:-http://navidrome:4533}"
ND_USER="${ND_USER:-}"
ND_PASS="${ND_PASS:-}"
@@ -46,8 +46,10 @@ if [ "$n" -lt "$MIN_ARTIST_DIRS" ]; then
fi
log "library healthy ($n artist dirs) — triggering full scan"
resp=$(curl -s -G "$ND/rest/startScan.view" \
--data-urlencode "u=$ND_USER" --data-urlencode "p=$ND_PASS" \
# Pass the password via stdin (--data-urlencode "p@-"), not on the command
# line, so it isn't visible in ps/proc. printf %s avoids a trailing newline.
resp=$(printf '%s' "$ND_PASS" | curl -s -G "$ND/rest/startScan.view" \
--data-urlencode "u=$ND_USER" --data-urlencode "p@-" \
--data-urlencode 'v=1.16.0' --data-urlencode 'c=cron' \
--data-urlencode 'f=json' --data-urlencode 'fullScan=true' || true)
# Don't log "scan triggered" on faith: a rotated password or Navidrome error
+3 -2
View File
@@ -351,9 +351,10 @@ dedup_today_status() {
# 6. Navidrome
section "Navidrome"
ND_RESP=$(curl -s --connect-timeout 5 -G "$ND_BASE/rest/getScanStatus.view" \
# Password via stdin (p@-), not argv, so it isn't exposed in ps/proc.
ND_RESP=$(printf '%s' "$ND_PASS" | curl -s --connect-timeout 5 -G "$ND_BASE/rest/getScanStatus.view" \
--data-urlencode "u=$ND_USER" \
--data-urlencode "p=$ND_PASS" \
--data-urlencode "p@-" \
--data-urlencode 'v=1.16.0' --data-urlencode 'c=status' --data-urlencode 'f=json' 2>/dev/null)
ND_LINE=$(echo "$ND_RESP" | python3 -c "
import sys, json
+1 -1
View File
@@ -41,7 +41,7 @@ fi
# equivalent (same pattern as navidrome-scan.sh's share-health gate).
LIB="${MUSIC_DATA_DIR:-/data/music}/Library"
CANARY="$LIB/.navidrome-canary"
MIN_ARTIST_DIRS=500
MIN_ARTIST_DIRS="${MIN_ARTIST_DIRS:-500}"
if [[ ! -r "$CANARY" ]]; then
log "ERROR: canary $CANARY missing/unreadable — share looks unhealthy — aborting sync"
exit 1