| @@ -294,7 +294,7 @@ Push raw audio data into the live stream buffer. Format: **S16LE stereo PCM** at | |||||
| Requires `--audio-stdin`, `--audio-http`, or another configured stream source to feed the buffer. | Requires `--audio-stdin`, `--audio-http`, or another configured stream source to feed the buffer. | ||||
| **Request:** Binary body, `application/octet-stream`, raw S16LE stereo PCM bytes. Set `Content-Type` to `application/octet-stream` or `audio/L16`; other media types are rejected. | |||||
| **Request:** Binary body, `application/octet-stream`, raw S16LE stereo PCM bytes. Set `Content-Type` to `application/octet-stream` or `audio/L16`; other media types are rejected. Requests larger than 512 MiB are rejected with `413 Request Entity Too Large`. | |||||
| **Response:** | **Response:** | ||||
| ```json | ```json | ||||
| @@ -325,6 +325,7 @@ ffmpeg -i song.mp3 -f s16le -ar 44100 -ac 2 - | \ | |||||
| **Errors:** | **Errors:** | ||||
| - `405` if not POST | - `405` if not POST | ||||
| - `415` if Content-Type is missing or unsupported (must be `application/octet-stream` or `audio/L16`) | - `415` if Content-Type is missing or unsupported (must be `application/octet-stream` or `audio/L16`) | ||||
| - `413` if the upload body exceeds the 512 MiB limit | |||||
| - `503` if no audio stream configured | - `503` if no audio stream configured | ||||
| --- | --- | ||||
| @@ -443,12 +443,14 @@ Diese Punkte könnten ggf. vorgezogen werden, auch wenn WS-05 formal nach WS-01/ | |||||
| ## WS-05 Entscheidungslog | ## WS-05 Entscheidungslog | ||||
| - 2026-04-06: `/audio/stream` now enforces a binary `Content-Type` (`application/octet-stream` or `audio/L16`) before queuing any samples. | - 2026-04-06: `/audio/stream` now enforces a binary `Content-Type` (`application/octet-stream` or `audio/L16`) before queuing any samples. | ||||
| - 2026-04-06: `/audio/stream` caps uploads at 512 MiB and rejects larger bodies with `413 Request Entity Too Large` before touching the ring buffer. | |||||
| ## WS-05 Verifikation | ## WS-05 Verifikation | ||||
| | Datum | Fokus | Ergebnis | | | Datum | Fokus | Ergebnis | | ||||
| |---|---|---| | |---|---|---| | ||||
| | 2026-04-05 | `/audio/stream` rejects non-POST requests | `TestAudioStreamRejectsNonPost` enforces POST-only access to `/audio/stream` before a stream source is configured | | | 2026-04-05 | `/audio/stream` rejects non-POST requests | `TestAudioStreamRejectsNonPost` enforces POST-only access to `/audio/stream` before a stream source is configured | | ||||
| | 2026-04-06 | `/audio/stream` enforces binary Content-Type headers | `TestAudioStreamRejectsMissingContentType` and `TestAudioStreamRejectsUnsupportedContentType` confirm 415 when the media type is missing or wrong | | | 2026-04-06 | `/audio/stream` enforces binary Content-Type headers | `TestAudioStreamRejectsMissingContentType` and `TestAudioStreamRejectsUnsupportedContentType` confirm 415 when the media type is missing or wrong | | ||||
| | 2026-04-06 | `/audio/stream` rejects oversized uploads | `TestAudioStreamRejectsBodyTooLarge` confirms a 413 Request Entity Too Large before buffering when the HTTP body exceeds the 512 MiB guard | | |||||
| --- | --- | ||||
| @@ -3,6 +3,7 @@ package control | |||||
| import ( | import ( | ||||
| _ "embed" | _ "embed" | ||||
| "encoding/json" | "encoding/json" | ||||
| "errors" | |||||
| "io" | "io" | ||||
| "mime" | "mime" | ||||
| "net/http" | "net/http" | ||||
| @@ -56,6 +57,7 @@ const ( | |||||
| configContentTypeHeader = "application/json" | configContentTypeHeader = "application/json" | ||||
| noBodyErrMsg = "request must not include a body" | noBodyErrMsg = "request must not include a body" | ||||
| audioStreamContentTypeError = "Content-Type must be application/octet-stream or audio/L16" | audioStreamContentTypeError = "Content-Type must be application/octet-stream or audio/L16" | ||||
| audioStreamBodyLimitDefault = 512 << 20 // 512 MiB | |||||
| ) | ) | ||||
| var audioStreamAllowedMediaTypes = []string{ | var audioStreamAllowedMediaTypes = []string{ | ||||
| @@ -63,6 +65,8 @@ var audioStreamAllowedMediaTypes = []string{ | |||||
| "audio/l16", | "audio/l16", | ||||
| } | } | ||||
| var audioStreamBodyLimit = int64(audioStreamBodyLimitDefault) // bytes allowed per /audio/stream request; tests may override. | |||||
| func isJSONContentType(r *http.Request) bool { | func isJSONContentType(r *http.Request) bool { | ||||
| ct := strings.TrimSpace(r.Header.Get("Content-Type")) | ct := strings.TrimSpace(r.Header.Get("Content-Type")) | ||||
| if ct == "" { | if ct == "" { | ||||
| @@ -284,6 +288,8 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) { | |||||
| return | return | ||||
| } | } | ||||
| r.Body = http.MaxBytesReader(w, r.Body, audioStreamBodyLimit) | |||||
| // Read body in chunks and push to ring buffer | // Read body in chunks and push to ring buffer | ||||
| buf := make([]byte, 32768) | buf := make([]byte, 32768) | ||||
| totalFrames := 0 | totalFrames := 0 | ||||
| @@ -296,6 +302,11 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) { | |||||
| if err == io.EOF { | if err == io.EOF { | ||||
| break | break | ||||
| } | } | ||||
| var maxErr *http.MaxBytesError | |||||
| if errors.As(err, &maxErr) { | |||||
| http.Error(w, maxErr.Error(), http.StatusRequestEntityTooLarge) | |||||
| return | |||||
| } | |||||
| http.Error(w, err.Error(), http.StatusInternalServerError) | http.Error(w, err.Error(), http.StatusInternalServerError) | ||||
| return | return | ||||
| } | } | ||||
| @@ -388,6 +388,28 @@ func TestAudioStreamRejectsUnsupportedContentType(t *testing.T) { | |||||
| } | } | ||||
| } | } | ||||
| func TestAudioStreamRejectsBodyTooLarge(t *testing.T) { | |||||
| orig := audioStreamBodyLimit | |||||
| t.Cleanup(func() { | |||||
| audioStreamBodyLimit = orig | |||||
| }) | |||||
| audioStreamBodyLimit = 1024 | |||||
| limit := int(audioStreamBodyLimit) | |||||
| body := make([]byte, limit+1) | |||||
| srv := NewServer(cfgpkg.Default()) | |||||
| srv.SetStreamSource(audio.NewStreamSource(256, 44100)) | |||||
| rec := httptest.NewRecorder() | |||||
| req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader(body)) | |||||
| req.Header.Set("Content-Type", "application/octet-stream") | |||||
| srv.Handler().ServeHTTP(rec, req) | |||||
| if rec.Code != http.StatusRequestEntityTooLarge { | |||||
| t.Fatalf("expected 413 for oversized body, got %d", rec.Code) | |||||
| } | |||||
| if !strings.Contains(rec.Body.String(), "request body too large") { | |||||
| t.Fatalf("unexpected response body: %q", rec.Body.String()) | |||||
| } | |||||
| } | |||||
| func TestTXStartWithoutController(t *testing.T) { | func TestTXStartWithoutController(t *testing.T) { | ||||
| srv := NewServer(cfgpkg.Default()) | srv := NewServer(cfgpkg.Default()) | ||||
| rec := httptest.NewRecorder() | rec := httptest.NewRecorder() | ||||