Files

106 lines
3.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""Read-only HTTP server for archived SDR recordings (runs on home-server, stdlib only).
GET/HEAD /YYYY-MM-DD/sdr_YYYYMMDD_HHMMSS_FREQ.mp3 (Range supported)
GET /health -> "ok"
Nothing else: no directory listing, no other file types, no path traversal.
Only the Pi's sdr_web talks to it (firewall); it proxies audio to the radio page.
"""
import argparse
import os
import re
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
PATH_RE = re.compile(r"^/(\d{4}-\d{2}-\d{2})/([A-Za-z0-9-]+_\d{8}_\d{6}_\d+\.mp3)$")
ROOT = "/srv/storage/sdr-archive"
class H(BaseHTTPRequestHandler):
server_version = "sdr-archive/1"
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *a):
pass
def small(self, code, text):
b = text.encode()
self.send_response(code)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(b)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(b)
def do_POST(self):
self.small(405, "method not allowed")
do_PUT = do_DELETE = do_PATCH = do_OPTIONS = do_POST
def do_HEAD(self):
self.do_GET()
def do_GET(self):
if "\x00" in self.path or ".." in self.path:
return self.small(404, "not found")
if self.path == "/health":
return self.small(200, "ok")
m = PATH_RE.match(self.path.split("?", 1)[0])
if not m:
return self.small(404, "not found")
path = os.path.join(ROOT, m[1], m[2])
try:
fh = open(path, "rb")
except OSError:
return self.small(404, "not found")
with fh:
size = os.fstat(fh.fileno()).st_size
start, end, code = 0, size - 1, 200
rng = self.headers.get("Range")
if rng:
r = re.match(r"bytes=(\d*)-(\d*)$", rng.strip())
if not r or (r[1] == "" and r[2] == ""):
return self.small(416, "")
if r[1] == "":
start = max(0, size - int(r[2]))
else:
start = int(r[1])
if r[2] != "":
end = min(int(r[2]), size - 1)
if start >= size or start > end:
return self.small(416, "")
code = 206
length = end - start + 1
self.send_response(code)
self.send_header("Content-Type", "audio/mpeg")
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Length", str(length))
self.send_header("Cache-Control", "public, max-age=86400")
self.send_header("X-Content-Type-Options", "nosniff")
if code == 206:
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
self.end_headers()
if self.command == "HEAD":
return
fh.seek(start)
left = length
try:
while left > 0:
chunk = fh.read(min(65536, left))
if not chunk:
break
self.wfile.write(chunk)
left -= len(chunk)
except (BrokenPipeError, ConnectionResetError):
pass
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--bind", default="127.0.0.1")
ap.add_argument("--port", type=int, default=18091)
ap.add_argument("--root", default=ROOT)
a = ap.parse_args()
ROOT = a.root
ThreadingHTTPServer.daemon_threads = True
ThreadingHTTPServer((a.bind, a.port), H).serve_forever()