Radio: Titel-Abfrage (/radio/sse/) nie ins private Netz; ohne Konto nur Sender aus dem Verzeichnis (kuratiert, Featured, radio-browser per Sender-ID) – eigene Adressen brauchen ein Konto; sw v146
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 10m37s
Test / e2e (push) Failing after 1m28s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-24 16:14:05 +02:00
parent 868e979755
commit 262056b5ee
6 changed files with 180 additions and 6 deletions

View file

@ -25,9 +25,16 @@ Endpoints (`radio/urls.py`):
the featured stations come rendered into it (`#saved-tbody`, `#history-tbody`, the featured stations come rendered into it (`#saved-tbody`, `#history-tbody`,
`.featured-section`) and as `INITIAL_SAVED` (with `play_count`), `INITIAL_FEATURED`, `.featured-section`) and as `INITIAL_SAVED` (with `play_count`), `INITIAL_FEATURED`,
`USER_FOCUS_STATION`. The apps take these parts from it (see below). `USER_FOCUS_STATION`. The apps take these parts from it (see below).
- `GET /radio/sse/?url=` — server-sent events with the track title, `data: {"track": …}` when it - `GET /radio/sse/?url=&station=` — server-sent events with the track title, `data: {"track": …}` when it
changes, `{"error": "stream ended"}` at the end. The server opens the stream itself with changes, `{"error": "stream ended"}` at the end. The server opens the stream itself with
`Icy-MetaData: 1` and reads `StreamTitle` (`radio/icy.py`). `Icy-MetaData: 1` and reads `StreamTitle` (`radio/icy.py`). Which addresses it opens
(`radio/streamcheck.py`, since 2026-10-24): never one in a private network (loopback,
10.x, 172.16–31.x, 192.168.x, link-local, the Docker network); **without an account only the
directory's stations** — the page's curated lists (read from `16-stations.js`), the featured
stations, the creamfresh stream, and what radio-browser.info lists (`station=` its station
id, which the page sends for search results; else by the address; kept a day). An address of
one's own is for saving, and saving needs an account: signed out, a pasted address offers
signing in instead of ▶.
- `POST /radio/record/` — a track into the history; `scrobble: true` sends it to Last.fm when the - `POST /radio/record/` — a track into the history; `scrobble: true` sends it to Last.fm when the
account has it linked (`radio/lastfm.py`; "Artist - Title", else the station as artist). account has it linked (`radio/lastfm.py`; "Artist - Title", else the station as artist).
- `POST /radio/save/` (by url: create or update), `/radio/remove/<pk>/`, `/radio/favorite/<pk>/`, - `POST /radio/save/` (by url: create or update), `/radio/remove/<pk>/`, `/radio/favorite/<pk>/`,
@ -124,8 +131,6 @@ login; `backgroundThrottling: false` keeps the titles coming.
## Known weak spots ## Known weak spots
- `/radio/sse/` needs no login and connects to any address it is given — a way to make the
server fetch arbitrary URLs. Should require a session (or a saved/featured station).
- The login for the creamfresh stream is written into `radio/views.py`; it belongs in the - The login for the creamfresh stream is written into `radio/views.py`; it belongs in the
environment like the other secrets. environment like the other secrets.
- Every listener's titles cost the server a second connection to the stream. - Every listener's titles cost the server a second connection to the stream.

84
radio/streamcheck.py Normal file
View file

@ -0,0 +1,84 @@
"""Which stream addresses the server may open for track titles (/radio/sse/, docs/radio.md).
- Never an address inside a private network (loopback, 10.x, 172.16-31.x, 192.168.x,
link-local, the Docker network …): the server would otherwise reach what only it can see.
- Without an account only the directory's stations: the page's curated lists, the featured
ones, and those radio-browser.info lists (by its station id, or by the address). Bringing an address of
one's own is for saving it, and saving needs an account.
"""
import functools
import hashlib
import ipaddress
import re
import socket
from pathlib import Path
from urllib.parse import urlparse
import requests
from django.core.cache import cache
from .models import FeaturedStation
DIRECTORY = 'https://de1.api.radio-browser.info/json/stations'
# The page's curated lists (SomaFM, SWR2 …) are part of the directory too; their addresses
# are read from where the page has them, so they stand in one place only.
CURATED_JS = Path(__file__).resolve().parent.parent / 'static' / 'js' / 'app' / '16-stations.js'
DIRECTORY_TIMEOUT = 6
DIRECTORY_CACHE = 24 * 3600
def public_address(url):
"""True when url is http(s) and every address its host resolves to is a public one."""
try:
parsed = urlparse(url)
except ValueError:
return False
if parsed.scheme not in ('http', 'https') or not parsed.hostname:
return False
try:
infos = socket.getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == 'https' else 80),
proto=socket.IPPROTO_TCP)
except (OSError, UnicodeError, ValueError):
return False
addresses = {info[4][0] for info in infos}
return bool(addresses) and all(ipaddress.ip_address(a.split('%')[0]).is_global for a in addresses)
def _directory_lists(url, uuid):
if uuid:
try:
r = requests.get(f'{DIRECTORY}/byuuid/{uuid}', timeout=DIRECTORY_TIMEOUT,
headers={'User-Agent': 'diora/1.0'})
if r.ok and any(url in (s.get('url'), s.get('url_resolved')) for s in r.json()):
return True
except (requests.RequestException, ValueError):
pass
try:
r = requests.get(f'{DIRECTORY}/byurl', params={'url': url}, timeout=DIRECTORY_TIMEOUT,
headers={'User-Agent': 'diora/1.0'})
return bool(r.ok and r.json())
except (requests.RequestException, ValueError):
return False
@functools.lru_cache(maxsize=1)
def curated():
try:
return frozenset(re.findall(r"url: '([^']+)'", CURATED_JS.read_text(encoding='utf-8')))
except OSError:
return frozenset()
def in_directory(url, uuid='', own_host=''):
"""A station of the directory: curated on the page, featured, ours (the creamfresh stream
through this server), or listed by radio-browser.info. Answers are kept a day."""
if url in curated() or FeaturedStation.objects.filter(url=url, active=True).exists():
return True
if own_host and urlparse(url).netloc == own_host:
return True
key = 'radio:dir:' + hashlib.sha1(f'{url} {uuid}'.encode()).hexdigest()
known = cache.get(key)
if known is None:
known = _directory_lists(url, uuid)
cache.set(key, known, DIRECTORY_CACHE)
return known

View file

@ -38,3 +38,63 @@ class PlayerPagePodcastsTests(TestCase):
sw = (static / 'js' / 'sw.js').read_text(encoding='utf-8') sw = (static / 'js' / 'sw.js').read_text(encoding='utf-8')
for path in ('/static/js/podcasts-web.js', '/static/podcasts/native-podcast.js'): for path in ('/static/js/podcasts-web.js', '/static/podcasts/native-podcast.js'):
self.assertIn(path, sw) self.assertIn(path, sw)
class SseGuardTests(TestCase):
"""/radio/sse/ opens a stream for its titles: never in a private network, and without an
account only the directory's stations (radio/streamcheck.py, docs/radio.md)."""
PUBLIC = [(2, 1, 6, '', ('93.184.216.34', 80))]
STREAM = 'http://stream.example.org/live.mp3'
def setUp(self):
from django.core.cache import cache
cache.clear()
def sse(self, url, **more):
from unittest import mock
with mock.patch('radio.views.stream_icy_metadata', return_value=iter(['Artist - Title'])):
return self.client.get('/radio/sse/', {'url': url, **more})
def test_private_addresses_never(self):
User.objects.create_user('r', password='pw12345678')
self.client.login(username='r', password='pw12345678')
for url in ['http://127.0.0.1:8000/', 'http://10.0.0.5/stream', 'http://169.254.169.254/latest/',
'http://[::1]/x', 'file:///etc/passwd', 'gopher://example.org/']:
self.assertEqual(self.sse(url).status_code, 403, url)
def test_without_account_only_the_directory(self):
from unittest import mock
with mock.patch('radio.streamcheck.socket.getaddrinfo', return_value=self.PUBLIC):
with mock.patch('radio.streamcheck.requests.get') as get:
get.return_value = mock.Mock(ok=True, json=lambda: [])
self.assertEqual(self.sse(self.STREAM).status_code, 403) # not listed: an own address
listed = [{'url': 'http://stream.example.org/pls', 'url_resolved': self.STREAM}]
get.return_value = mock.Mock(ok=True, json=lambda: listed)
self.assertEqual(self.sse(self.STREAM, station='abc-uuid').status_code, 200)
def test_featured_stations_without_account(self):
from unittest import mock
from .models import FeaturedStation
FeaturedStation.objects.create(name='F', url=self.STREAM)
with mock.patch('radio.streamcheck.socket.getaddrinfo', return_value=self.PUBLIC), \
mock.patch('radio.streamcheck.requests.get') as get:
self.assertEqual(self.sse(self.STREAM).status_code, 200)
get.assert_not_called()
def test_own_addresses_with_an_account(self):
from unittest import mock
User.objects.create_user('r', password='pw12345678')
self.client.login(username='r', password='pw12345678')
with mock.patch('radio.streamcheck.socket.getaddrinfo', return_value=self.PUBLIC), \
mock.patch('radio.streamcheck.requests.get') as get:
self.assertEqual(self.sse(self.STREAM).status_code, 200)
get.assert_not_called()
def test_the_pages_curated_stations_without_account(self):
from unittest import mock
from . import streamcheck
self.assertIn('https://ice6.somafm.com/dronezone-256-mp3', streamcheck.curated())
with mock.patch('radio.streamcheck.socket.getaddrinfo', return_value=self.PUBLIC), mock.patch('radio.streamcheck.requests.get') as get:
self.assertEqual(self.sse('https://ice6.somafm.com/dronezone-256-mp3').status_code, 200)
get.assert_not_called()

View file

@ -12,6 +12,7 @@ from django.conf import settings
from django.http import ( from django.http import (
HttpResponse, HttpResponse,
HttpResponseBadRequest, HttpResponseBadRequest,
HttpResponseForbidden,
JsonResponse, JsonResponse,
StreamingHttpResponse, StreamingHttpResponse,
) )
@ -22,6 +23,7 @@ from django.views.decorators.http import require_http_methods
from .icy import stream_icy_metadata from .icy import stream_icy_metadata
from . import lastfm as lastfm_module from . import lastfm as lastfm_module
from . import streamcheck
from .models import SavedStation, StationPlay, TrackHistory, FocusSession, FeaturedStation from .models import SavedStation, StationPlay, TrackHistory, FocusSession, FeaturedStation
@ -98,9 +100,16 @@ def index(request):
@csrf_exempt @csrf_exempt
def sse_metadata(request): def sse_metadata(request):
"""The stream's track titles. Never from a private network; without an account only for
the directory's stations (radio/streamcheck.py, docs/radio.md)."""
url = request.GET.get('url', '').strip() url = request.GET.get('url', '').strip()
if not url: if not url:
return HttpResponseBadRequest('url parameter required') return HttpResponseBadRequest('url parameter required')
if not streamcheck.public_address(url):
return HttpResponseForbidden('not a public stream address')
if not request.user.is_authenticated and not streamcheck.in_directory(
url, request.GET.get('station', '').strip()[:64], request.get_host()):
return HttpResponseForbidden('own stream addresses need an account')
def event_stream(): def event_stream():
last_title = None last_title = None

View file

@ -235,7 +235,11 @@ if (volWheelTarget) {
function startMetadataSSE(streamUrl) { function startMetadataSSE(streamUrl) {
if (sseSource) { sseSource.close(); sseSource = null; } if (sseSource) { sseSource.close(); sseSource = null; }
const endpoint = '/radio/sse/?url=' + encodeURIComponent(streamUrl); // A station from the directory says which (radio-browser's id): without an account the
// server opens only the directory's stations (radio/streamcheck.py).
const dir = window._directoryStation;
const endpoint = '/radio/sse/?url=' + encodeURIComponent(streamUrl) +
(dir && dir.url === streamUrl && dir.uuid ? '&station=' + encodeURIComponent(dir.uuid) : '');
sseSource = new EventSource(endpoint); sseSource = new EventSource(endpoint);
sseSource.onmessage = function (e) { sseSource.onmessage = function (e) {
@ -421,6 +425,7 @@ async function doSearch() {
country: st.country || '', country: st.country || '',
tags: st.tags || '', tags: st.tags || '',
favicon_url: st.favicon || '', favicon_url: st.favicon || '',
uuid: st.stationuuid || '',
})})'> })})'>
&#9654; Abspielen &#9654; Abspielen
</button> </button>
@ -470,6 +475,16 @@ function showStreamAddressResult(url) {
tr.children[3].title = shown; tr.children[3].title = shown;
const actions = document.createElement('td'); const actions = document.createElement('td');
// An address of one's own is there to be saved, and saving needs an account; without one
// the directory is what plays (docs/radio.md).
if (typeof IS_AUTHENTICATED !== 'undefined' && !IS_AUTHENTICATED) {
actions.innerHTML = '<span class="muted">Eigene Adressen mit Konto: <a href="/accounts/login/">Anmelden</a> oder ' +
'<a href="/accounts/register/">registrieren</a></span>';
tr.appendChild(actions);
tbody.appendChild(tr);
tableEl.style.display = '';
return;
}
const play = document.createElement('button'); const play = document.createElement('button');
play.className = 'btn btn-sm btn-play'; play.className = 'btn btn-sm btn-play';
play.innerHTML = '&#9654; Abspielen'; play.innerHTML = '&#9654; Abspielen';
@ -495,6 +510,7 @@ function showStreamAddressResult(url) {
function searchPlay(url, name, stationData) { function searchPlay(url, name, stationData) {
// Store station data on the window so saveCurrentStation() can use it // Store station data on the window so saveCurrentStation() can use it
window._pendingStationData = stationData; window._pendingStationData = stationData;
window._directoryStation = stationData && stationData.uuid ? {url, uuid: stationData.uuid} : null;
playStation(url, name, null); playStation(url, name, null);
} }

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use. * diora service worker — caches the app shell for offline use.
*/ */
const CACHE = 'diora-v145'; const CACHE = 'diora-v146';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
'/static/js/app/01-core.js', '/static/js/app/01-core.js',