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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

**Music and artwork now need a credential on a server with a token** (ADR-0176 Phase 4). The apps
send one from 1.7 on, the web admin uses a cookie, and speakers get a link that expires. An iPhone
or Mac still on Familiar 1.5.1 or earlier stops playing from a server with a token after this
upgrade, so update the apps first. The server log names each device it refuses, once an hour,
with that reason. A server with no token is unaffected.

### Changed

- Media on a tokened server is refused without the token header, the media cookie or a
server-signed URL. It is served `Cache-Control: private`, so a shared cache cannot serve one
listener's library to another.

## [0.2.0-beta24] - 2026-11-10

One migration (`20261109_embed_failure_reason` adds two columns to `track_analysis` and backfills
Expand Down
47 changes: 41 additions & 6 deletions backend/app/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,16 @@
from __future__ import annotations

import hmac
import logging
import re
import secrets

from starlette.types import ASGIApp, Receive, Scope, Send

from app.api.exceptions import ErrorCode

logger = logging.getLogger(__name__)

TOKEN_HEADER = "X-Familiar-Token"

#: Paths that never require a token, matched against the path with any trailing slash removed.
Expand Down Expand Up @@ -83,7 +86,8 @@ def token_matches(presented: str | None, configured: str | None) -> bool:
return hmac.compare_digest(presented, configured)


#: Media, readable without a token (ADR-0045, "Media is exempt from the gate, decided 2026-08-09").
#: Media: outside the token gate (ADR-0045, "Media is exempt from the gate, decided 2026-08-09"),
#: but since ADR-0176 Phase 4 not open — see the end of this comment.
#:
#: **Recorded as decided for six weeks before it existed.** Until 2026-09-29 every `/api/` path was
#: gated, so configuring a token broke `<img>` artwork in the web admin, every stream a WiiM or
Expand All @@ -97,9 +101,9 @@ def token_matches(presented: str | None, configured: str | None) -> bool:
#: leak (ADR-0045, ADR-0134 point 3): an oracle over the collection. Since ADR-0176 Phase 1 a hash
#: key is served only with this server's MAC (`app/services/artwork_keys.py`), on the image route and
#: on `/artwork/status/{key}` (which the second pattern below also matches); a bare hash is a 404.
#: A media read is authorized by the header, a media cookie or a server-signed URL
#: (`app/api/media_credentials.py`, ADR-0176 Phase 2), but none is *required* until Phase 4 turns on
#: `MEDIA_ENFORCED`: the App Store builds 1.6 and 1.7 send no credential on media.
#: These shapes are not behind *the token*, but they are not open either: since ADR-0176 Phase 4 a
#: media read on a tokened server needs one of three credentials — the header, a media cookie or a
#: server-signed URL (`app/api/media_credentials.py`, `TokenAuthMiddleware._media_refused`).
MEDIA_ROUTES = tuple(
re.compile(pattern)
for pattern in (
Expand Down Expand Up @@ -159,6 +163,36 @@ def extract_token(scope: Scope) -> str | None:
"web admin for its media cookie, or use a URL this server signed."
)

#: How long a client that was refused media goes before it is named in the log again.
MEDIA_REFUSAL_LOG_INTERVAL_S = 3600.0
_media_refusals_logged: dict[str, float] = {}


def _warn_media_refused(scope: Scope) -> None:
"""Say in the server log why a listener's app went silent (ADR-0176 Phase 4).

An app from before 1.7 sends no credential on media. It shows the listener nothing useful, so
the server log is where the person running the server can find out. Once per client an hour,
since a player asks for audio and artwork many times a minute.
"""
import time

client = scope.get("client")
host = client[0] if client else "unknown"
now = time.monotonic()
last = _media_refusals_logged.get(host)
if last is not None and now - last < MEDIA_REFUSAL_LOG_INTERVAL_S:
return
if len(_media_refusals_logged) > 1024:
_media_refusals_logged.clear()
_media_refusals_logged[host] = now
logger.warning(
"Refused media to %s: it sent no credential. An iPhone or Mac app older than Familiar 1.7 "
"does this; update the app. Media on a server with a token needs one (ADR-0176).",
host,
)


#: What `/mcp` answers on an open server with no Host allow-list (ADR-0171 point 7).
OPEN_MCP_DETAIL = (
"MCP is off on a server with no token, because nothing else would stop a web page from reaching "
Expand Down Expand Up @@ -248,8 +282,8 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
async def _media_refused(scope: Scope, path: str, method: str, send: Send) -> bool:
"""ADR-0176: on a tokened server, a media read needs a header, cookie or signed URL.

Only once `MEDIA_ENFORCED` is on (Phase 4). Until then this returns False before reading
anything, and media stays exempt as `MEDIA_ROUTES` records.
On since Phase 4 (`MEDIA_ENFORCED`). Switched off, this returns False before reading
anything, and media is served to anyone, as it was before ADR-0176.
"""
from app.api import media_credentials as media

Expand All @@ -260,6 +294,7 @@ async def _media_refused(scope: Scope, path: str, method: str, send: Send) -> bo
configured = get_app_settings_service().get().access_token
if not configured or media.presented_credential(scope, configured) is not None:
return False
_warn_media_refused(scope)
await TokenAuthMiddleware._refuse(scope, send, detail=MEDIA_CREDENTIAL_DETAIL)
return True

Expand Down
13 changes: 7 additions & 6 deletions backend/app/api/media_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
invalidates cookies in the same moment it signs clients out (point 5). Nothing new is stored: with
no token there is nothing to derive from, no URL is signed, and ADR-0171's exposure rules decide.

**Not enforced yet** (`MEDIA_ENFORCED`). Point 8 waits until no App Store build that omits the header
is still offered — 1.6 and 1.7 (build 78) send nothing on media. Until then the gate checks nothing on
media, and this module is exercised by its tests and by the clients that already send credentials.
Phase 4 is flipping `MEDIA_ENFORCED`, then making media `Cache-Control: private`.
**Enforced since Phase 4** (`MEDIA_ENFORCED`, 2026-11-11). Point 8's trigger was met when app 1.7
(build 80), which sends the header, became the only App Store build offered on iOS and macOS: a
tokened server refuses a media read that carries none of the three. Media is served
`Cache-Control: private` so a shared cache cannot hand one listener's library to another (point 6).
"""

from __future__ import annotations
Expand All @@ -33,8 +33,9 @@
# Signatures are kept out of logs by `app.logging_config.redact_credentials`, which the access log
# applies to every line (point 6).

#: Phase 4 of ADR-0176. While False, a media request with no credential is still served.
MEDIA_ENFORCED = False
#: Phase 4 of ADR-0176: on a tokened server, a media read with no credential is refused. A module
#: constant rather than a setting, so a server cannot be talked back into the old exemption.
MEDIA_ENFORCED = True

COOKIE_NAME = "familiar_media"
#: The cookie is sent only to the API, and honoured only on media reads (`presented_credential`).
Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/routes/artwork.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ async def get_artwork_by_hash(album_hash: str, size: str) -> Any:
return FileResponse(
artwork_path,
media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=31536000"},
headers={"Cache-Control": "private, max-age=31536000"},
)


Expand Down
8 changes: 4 additions & 4 deletions backend/app/api/routes/library_artists.py
Original file line number Diff line number Diff line change
Expand Up @@ -827,7 +827,7 @@ def is_valid_image(url: str | None) -> bool:
if is_valid_image(artist.image_url):
return RedirectResponse(
url=artist.image_url,
headers={"Cache-Control": "public, max-age=86400"},
headers={"Cache-Control": "private, max-age=86400"},
)

# Step 2: Try Last.fm. Promote to Artist.image_url on success so the
Expand Down Expand Up @@ -856,7 +856,7 @@ def is_valid_image(url: str | None) -> bool:
await db.commit()
return RedirectResponse(
url=image_url,
headers={"Cache-Control": "public, max-age=86400"},
headers={"Cache-Control": "private, max-age=86400"},
)
except Exception as e:
logger.debug(f"Last.fm artist image lookup failed for '{artist.name}': {e}")
Expand Down Expand Up @@ -887,7 +887,7 @@ def stream_artwork():
return StreamingResponse(
stream_artwork(),
media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=31536000"},
headers={"Cache-Control": "private, max-age=31536000"},
)

file_path = Path(track.file_path)
Expand All @@ -902,7 +902,7 @@ def stream_artwork():
return StreamingResponse(
stream_artwork(),
media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=31536000"},
headers={"Cache-Control": "private, max-age=31536000"},
)

raise NotFoundError("No artist image available")
2 changes: 1 addition & 1 deletion backend/app/api/routes/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ async def get_avatar(
return FileResponse(
avatar_file,
media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=86400"}, # Cache for 1 day
headers={"Cache-Control": "private, max-age=86400"}, # Cache for 1 day
)


Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/routes/tracks/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ def stream_artwork() -> Iterator[bytes]:
stream_artwork(), # type: ignore[no-untyped-call]
media_type="image/jpeg",
headers={
"Cache-Control": "public, max-age=31536000", # Cache for 1 year
"Cache-Control": "private, max-age=31536000", # a year; private since ADR-0176 Phase 4
},
)

Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/routes/videos.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ async def get_video_poster(track_id: UUID) -> FileResponse:
return FileResponse(
poster_path,
media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=31536000"},
headers={"Cache-Control": "private, max-age=31536000"},
)


Expand Down
6 changes: 4 additions & 2 deletions backend/app/services/artwork_keys.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""Artwork keys a caller cannot derive (ADR-0176 Phase 1).

Artwork is served without the server token (`MEDIA_ROUTES`, ADR-0045), because an `<img>` and a
casting device cannot send one. That is safe only for a key nobody can guess. Since ADR-0052 an
Artwork was served without any credential (`MEDIA_ROUTES`, ADR-0045), because an `<img>` and a
casting device cannot send the token; since ADR-0176 Phase 4 it takes a cookie or a signed URL
instead. A tokenless server still serves it to whoever ADR-0171 lets in, so a key must stay one
nobody can guess. Since ADR-0052 an
album's key is its `Album.id`, a UUID. But a track the album resolver could not place still files
its artwork under `compute_album_hash(artist, album)`: sixteen hex digits anyone can compute for a
guessed album. Asking `GET /api/v1/artwork/<that>/thumb` (or `/artwork/status/<that>`) answered
Expand Down
14 changes: 11 additions & 3 deletions backend/tests/test_inbound_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,10 @@ def test_masked_settings_do_not_contain_it(self, tmp_path):
class TestMediaIsExempt:
"""ADR-0045's media exemption, which was recorded as decided on 2026-08-09 and did not exist
until 2026-09-29. Without it, configuring a token broke `<img>` artwork in the web admin, every
stream a WiiM or Sonos fetches for itself, and the Apple app's audio and artwork."""
stream a WiiM or Sonos fetches for itself, and the Apple app's audio and artwork.

Since ADR-0176 Phase 4 these shapes are outside the *token* gate only: each needs the header, a
media cookie or a signed URL, which the middleware checks separately (`_media_refused`)."""

MEDIA = [
"/api/v1/tracks/3fa85f64-5717-4562-b3fc-2c963f66afa6/stream",
Expand Down Expand Up @@ -319,8 +322,13 @@ def test_every_exempt_shape_is_a_real_route(self):
assert any(pattern.match(p) for p in get_paths), f"no GET route matches {pattern.pattern}"

@pytest.mark.asyncio
async def test_the_middleware_serves_media_to_a_client_with_no_token(self, monkeypatch):
async def test_the_middleware_refuses_media_with_no_credential(self, monkeypatch):
"""ADR-0176 Phase 4: outside the token gate is not open. `test_media_credentials.py` covers
the cookie and the signed URL; this pins the bare request and the header."""
path = "/api/v1/tracks/3fa85f64-5717-4562-b3fc-2c963f66afa6/stream"
passed, status = await TestTheMiddlewareGate._call(monkeypatch, "secret", {}, path)
assert not passed and status == 401
passed, status = await TestTheMiddlewareGate._call(
monkeypatch, "secret", {}, "/api/v1/tracks/3fa85f64-5717-4562-b3fc-2c963f66afa6/stream"
monkeypatch, "secret", {"X-Familiar-Token": "secret"}, path
)
assert passed and status is None
Loading
Loading