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`,
|
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
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')
|
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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 || '',
|
||||||
})})'>
|
})})'>
|
||||||
▶ Abspielen
|
▶ 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 = '▶ Abspielen';
|
play.innerHTML = '▶ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue