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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
868e979755
commit
262056b5ee
6 changed files with 180 additions and 6 deletions
|
|
@ -25,9 +25,16 @@ Endpoints (`radio/urls.py`):
|
|||
the featured stations come rendered into it (`#saved-tbody`, `#history-tbody`,
|
||||
`.featured-section`) and as `INITIAL_SAVED` (with `play_count`), `INITIAL_FEATURED`,
|
||||
`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
|
||||
`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
|
||||
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>/`,
|
||||
|
|
@ -124,8 +131,6 @@ login; `backgroundThrottling: false` keeps the titles coming.
|
|||
|
||||
## 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
|
||||
environment like the other secrets.
|
||||
- Every listener's titles cost the server a second connection to the stream.
|
||||
|
|
|
|||
84
radio/streamcheck.py
Normal file
84
radio/streamcheck.py
Normal 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
|
||||
|
|
@ -38,3 +38,63 @@ class PlayerPagePodcastsTests(TestCase):
|
|||
sw = (static / 'js' / 'sw.js').read_text(encoding='utf-8')
|
||||
for path in ('/static/js/podcasts-web.js', '/static/podcasts/native-podcast.js'):
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from django.conf import settings
|
|||
from django.http import (
|
||||
HttpResponse,
|
||||
HttpResponseBadRequest,
|
||||
HttpResponseForbidden,
|
||||
JsonResponse,
|
||||
StreamingHttpResponse,
|
||||
)
|
||||
|
|
@ -22,6 +23,7 @@ from django.views.decorators.http import require_http_methods
|
|||
|
||||
from .icy import stream_icy_metadata
|
||||
from . import lastfm as lastfm_module
|
||||
from . import streamcheck
|
||||
from .models import SavedStation, StationPlay, TrackHistory, FocusSession, FeaturedStation
|
||||
|
||||
|
||||
|
|
@ -98,9 +100,16 @@ def index(request):
|
|||
|
||||
@csrf_exempt
|
||||
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()
|
||||
if not url:
|
||||
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():
|
||||
last_title = None
|
||||
|
|
|
|||
|
|
@ -235,7 +235,11 @@ if (volWheelTarget) {
|
|||
function startMetadataSSE(streamUrl) {
|
||||
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.onmessage = function (e) {
|
||||
|
|
@ -421,6 +425,7 @@ async function doSearch() {
|
|||
country: st.country || '',
|
||||
tags: st.tags || '',
|
||||
favicon_url: st.favicon || '',
|
||||
uuid: st.stationuuid || '',
|
||||
})})'>
|
||||
▶ Abspielen
|
||||
</button>
|
||||
|
|
@ -470,6 +475,16 @@ function showStreamAddressResult(url) {
|
|||
tr.children[3].title = shown;
|
||||
|
||||
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');
|
||||
play.className = 'btn btn-sm btn-play';
|
||||
play.innerHTML = '▶ Abspielen';
|
||||
|
|
@ -495,6 +510,7 @@ function showStreamAddressResult(url) {
|
|||
function searchPlay(url, name, stationData) {
|
||||
// Store station data on the window so saveCurrentStation() can use it
|
||||
window._pendingStationData = stationData;
|
||||
window._directoryStation = stationData && stationData.uuid ? {url, uuid: stationData.uuid} : null;
|
||||
playStation(url, name, null);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* diora service worker — caches the app shell for offline use.
|
||||
*/
|
||||
|
||||
const CACHE = 'diora-v145';
|
||||
const CACHE = 'diora-v146';
|
||||
const SHELL = [
|
||||
'/static/css/app.css',
|
||||
'/static/js/app/01-core.js',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue