Skip to content

Fix dashboard buttons and add video support - #4

Open
dnicolson wants to merge 6 commits into
grave0x:mainfrom
dnicolson:fix-dashboard
Open

Fix dashboard buttons and add video support#4
dnicolson wants to merge 6 commits into
grave0x:mainfrom
dnicolson:fix-dashboard

Conversation

@dnicolson

@dnicolson dnicolson commented Aug 10, 2026

Copy link
Copy Markdown

This adds support for video playback and fixes some playback issues.

Can be tested with the nightly VLC build:

vlc-vv video.mp4 --sout '#chromecast' --sout-chromecast-ip=127.0.0.1 --demux-filter=demux_chromecast

Summary by Sourcery

Add spec-compliant media session handling, TLS compatibility improvements, and a dashboard video player with streaming proxy support.

New Features:

  • Expose current media via a same-origin /stream endpoint and HTML5 video player in the dashboard.
  • Support Cast media commands including LOAD, PLAY, PAUSE, STOP, SEEK, and volume control with mediaSessionId tracking and custom data handling.
  • Track launched Cast applications and media sessions on the receiver for more accurate RECEIVER_STATUS responses.

Bug Fixes:

  • Align media session fields and mediaSessionId usage with Cast protocol expectations to fix sender control issues.
  • Ensure dashboard play/pause/stop controls correctly update receiver state and broadcast media status to all senders.
  • Normalize IPv6 media URLs and support local file streaming to avoid playback failures in browsers.

Enhancements:

  • Replace ECDSA certificates with RSA CA-style self-signed certs and validate them for Cast TLS compatibility, regenerating legacy or invalid certificates on startup.
  • Move from TLS listener to a TCP listener with explicit TLS negotiation, adding ClientHello probing and a legacy TLS 1.2 RSA-AES-GCM fallback for trailing-dot SNI clients.
  • Improve receiver logging, app lifecycle management, and namespace source IDs for clearer session diagnostics and correct media transport behavior.

Tests:

  • Add unit tests covering TLS certificate generation and compatibility checks, TLS ClientHello probing, and legacy TLS 1.2 fallback eligibility.
  • Add tests for media autoplay semantics, mediaSessionId status payloads, stream readiness, time/volume clamping, and receiver volume isolation.
  • Add tests for HTTP proxy behavior, including range support for cached media and URL normalization, and for namespace source ID selection in Cast messages.

dnicolson and others added 6 commits August 10, 2026 07:27
Co-Authored-By: codex <codex@openai.com>
Co-Authored-By: codex <codex@openai.com>
Co-Authored-By: codex <codex@openai.com>
Co-Authored-By: codex <codex@openai.com>
Co-Authored-By: codex <codex@openai.com>
@sourcery-ai

sourcery-ai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR refactors TLS handling and media session management to add robust video playback support, improve Chromecast protocol compatibility, and enhance the web dashboard with a same-origin HTML5 video player and richer status/control APIs.

Sequence diagram for TLS probing and legacy TLS 1.2 fallback

sequenceDiagram
    participant Client
    participant Server as GoCastServer
    participant HR as handleRawConnection
    participant Legacy as legacyTLS12Conn
    participant TLS as tls.Server
    participant Cast as cast.HandleConnection

    Client->>Server: TCP connect
    Server->>HR: handleRawConnection(conn, tlsCfg, cert, receiver)
    HR->>HR: probeConnectionStart(conn, br)
    alt [ClientHello has trailing-dot SNI and legacyTLS12FallbackAvailable]
        HR->>Legacy: newLegacyTLS12ServerConn(bufferedConn, br, cert, hello)
        Note right of Legacy: handshake()
        HR->>Cast: cast.HandleConnection(Legacy, receiver)
    else [default TLS path]
        HR->>TLS: tls.Server(bufferedConn, cfg)
        TLS-->>HR: tlsConn
        HR->>Cast: cast.HandleConnection(tlsConn, receiver)
    end
Loading

Sequence diagram for dashboard HTML5 video playback and media proxy

sequenceDiagram
    participant Browser
    participant Dash as Dashboard/API
    participant Rec as Receiver
    participant Stream as streamHandler
    participant Proxy as ProxyMedia

    loop poll status
        Browser->>Dash: GET /api/status
        Dash->>Rec: read MediaSession
        Rec-->>Dash: playerState, contentId, mediaSessionId, streamReady
        Dash-->>Browser: JSON status
    end

    alt [status.streamReady]
        Browser->>Stream: GET /stream?session=...
        Stream->>Rec: read Media.CachePath & Media.Media
        alt [cacheComplete]
            Stream->>Proxy: ServeCachedMedia(w, r, cachePath, contentType)
        else [no cache file]
            Stream->>Proxy: ProxyMedia(w, r, source)
        end
        Proxy-->>Browser: media bytes (same-origin)
    end
Loading

File-Level Changes

Change Details Files
Introduce buffered connection probing and optional legacy TLS 1.2 RSA-AES-GCM fallback while tightening TLS configuration and certificate management.
  • Replace direct tls.Listen with a plain TCP listener that probes initial bytes to classify protocols and peek ClientHello records.
  • Add handleRawConnection to wrap probed sockets in tls.Server and log client capabilities, with optional legacy TLS 1.2 RSA fallback when trailing-dot SNI is detected.
  • Implement tlsClientHelloProbe and helpers to parse ClientHello/extension/SNI details and summarize them.
  • Add legacy_tls12.go implementing a minimal TLS 1.2 server for RSA_WITH_AES_128_GCM_SHA256 including manual handshake, key derivation, and record AEAD framing.
  • Refactor certificate loading into loadOrCreateTLSCertificate with castTLSCertCompatible checks and RSA-based generateTLSCertificate using IP SANs from local interfaces.
  • Tighten tls.Config to TLS 1.2 minimum, disable client cert auth, and advertise cast capabilities via ca flags.
main.go
legacy_tls12.go
main_test.go
Rework media session model and cast media protocol handling to support video playback, richer metadata, proper mediaSessionId semantics, and error/status messages.
  • Extend MediaSession/MediaInfo/Metadata with mediaSessionId, playbackRate, supportedMediaCommands bitmask, customData, richer metadata fields, and internal cache fields.
  • Redesign mediaRequest/mediaStatusMsg plus new mediaErrorMsg and volumePatch to align with Cast media protocol (autoplay, seek, volume, resumeState, customData).
  • Replace ffplay/local-file execution model with pure state management: LOAD/PLAY/PAUSE/STOP/SEEK/VOLUME handlers update Receiver.Media and broadcast status/errors.
  • Add helper functions for autoplay interpretation, playback rate, clamping time/volume, idleReason handling, and HTTP media URL normalization and readiness marking.
  • Add markStreamReady and validMediaSession plus Receiver.BroadcastMediaStatus to notify senders when streams become playable.
  • Add tests covering autoplay parsing, mediaSessionId JSON, status field coverage, stream readiness, clamping, and volume behavior.
cast/media.go
cast/media_test.go
Upgrade the web dashboard from a simple status card to a video-centric control surface that streams via same-origin endpoints and drives receiver state.
  • Replace dashboard HTML with a responsive layout that includes an HTML5
  • Change dashboard JS polling to consume expanded /api/status (duration, streamType, mediaSessionId, streamReady/error) and attach/sync the video src to /stream with session query.
  • Implement logic to synchronize receiver playerState with the browser video element, handle autoplay/mute errors, and keep UI fields in sync.
  • Extend dashboard Go server status JSON to include streamPath, readiness, errors, duration, and mediaSessionId.
  • Add /stream handler that serves cached media via ServeCachedMedia or proxied source via ProxyMedia, and make control handlers broadcast MEDIA_STATUS updates.
cast/dashboard.html
cast/dashboard.go
Align Cast session/receiver behavior with Chromecast expectations, including app lifecycle, volume model, media source IDs, and logging.
  • Change Session to use net.Conn and generic handshake interface, add senderID tracking and senderDestination, and log all inbound/outbound messages with namespaces and ids.
  • Update HandleConnection to perform handshake if supported and register/unregister sessions, and route nsMedia through handleMediaMessage using Session.
  • Implement receiverStatusMessage/receiverStatusPayload building dynamic applications list, activeInput, standby, and attenuation-based volume, using Receiver state instead of per-session apps.
  • Add Receiver.LaunchApp/CloseApp/AppStatus/NextMediaSessionID and volume clamping; wire LAUNCH and SET_VOLUME to these, and CLOSE to CloseApp.
  • Switch device auth, media, and receiver messages to use constant defaultReceiverID/defaultTransport source IDs determined by sourceIDForNamespace.
  • Add tests for namespace-based source ID selection.
cast/server.go
cast/receiver.go
cast/server_test.go
Generalize HTTP proxying to support both remote URLs and local file paths for media, with proper CORS and IPv6 zone normalization.
  • Refactor ProxyServer.handleStream into ProxyMedia which handles OPTIONS/GET/HEAD, decides between local file serving and upstream proxy, and uses hasScheme and normalizeHTTPMediaURL.
  • Add ServeCachedMedia with CORS headers and Range support for cached files served to the dashboard.
  • Factor out writeProxyCORS and hasScheme helpers, and use normalizeHTTPMediaURL when fetching pages and proxied media.
  • Add tests ensuring ServeCachedMedia respects ranges/CORS, hasScheme distinguishes local paths, and normalizeHTTPMediaURL correctly escapes IPv6 zone identifiers or leaves valid URLs unchanged.
cast/httpproxy.go
cast/httpproxy_test.go

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues, and left some high level feedback:

  • The TLS client hello probing and legacy TLS 1.2 fallback logic added to main.go and legacy_tls12.go is quite large and intertwined; consider extracting this into a dedicated package or subsystem to keep main.go focused on startup wiring and to make the handshake path easier to reason about and maintain.
  • In handleRawConnection, you clone tlsCfg into cfg but GetConfigForClient returns tlsCfg instead of cfg; if the intent is to customize per-client configuration (and use the modified MinVersion etc.), consider returning cfg from GetConfigForClient to avoid subtle differences between the initial and per-client configs.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The TLS client hello probing and legacy TLS 1.2 fallback logic added to main.go and legacy_tls12.go is quite large and intertwined; consider extracting this into a dedicated package or subsystem to keep main.go focused on startup wiring and to make the handshake path easier to reason about and maintain.
- In handleRawConnection, you clone tlsCfg into cfg but GetConfigForClient returns tlsCfg instead of cfg; if the intent is to customize per-client configuration (and use the modified MinVersion etc.), consider returning cfg from GetConfigForClient to avoid subtle differences between the initial and per-client configs.

## Individual Comments

### Comment 1
<location path="cast/receiver.go" line_range="74-76" />
<code_context>
 	return &Receiver{
 		sessions: make(map[string]*Session),
 		Media: &MediaSession{
-			PlayerState: "IDLE",
+			PlayerState:  "IDLE",
+			PlaybackRate: 1,
+			Volume:       Volume{Level: 1, Muted: false},
 		},
</code_context>
<issue_to_address>
**issue (bug_risk):** Initial PlaybackRate is set to 1 while PlayerState is IDLE, and dashboard controls don’t keep PlaybackRate in sync with state.

NewReceiver sets Media.PlaybackRate to 1 while PlayerState is "IDLE", but playbackRateForState returns 0 for non-playing states. The dashboard controlHandler mutates PlayerState without updating PlaybackRate, so PLAY/PAUSE/STOP actions can leave PlaybackRate inconsistent with PlayerState. Please initialize PlaybackRate to 0 for IDLE and update it via playbackRateForState whenever PlayerState changes (including in dashboard.go) to keep media status consistent.
</issue_to_address>

### Comment 2
<location path="cast/httpproxy.go" line_range="71-63" />
<code_context>
+		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+		return
+	}
+	if !hasScheme(source) {
+		if _, err := os.Stat(source); err != nil {
+			http.Error(w, "media file unavailable", http.StatusNotFound)
+			return
+		}
+		writeProxyCORS(w)
+		http.ServeFile(w, r, source)
+		return
</code_context>
<issue_to_address>
**🚨 issue (security):** Serving arbitrary local file paths via ProxyMedia can expose unexpected files or paths to the dashboard origin.

Because any source without a scheme is treated as a local path and passed directly to http.ServeFile if os.Stat succeeds, a sender-controlled contentId like "../../somefile" could trigger path traversal and expose arbitrary local files. Please constrain local file access to a specific base directory (or known cache location), normalize and validate paths, and reject requests that resolve outside that directory to prevent unintended file exposure.
</issue_to_address>

### Comment 3
<location path="cast/httpproxy_test.go" line_range="11-15" />
<code_context>
+	"testing"
+)
+
+func TestServeCachedMediaSupportsRanges(t *testing.T) {
+	f, err := os.CreateTemp(t.TempDir(), "media-*")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := f.WriteString("abcdef"); err != nil {
+		t.Fatal(err)
</code_context>
<issue_to_address>
**suggestion (testing):** ProxyMedia behavior (local file vs remote URL, method handling, OPTIONS/CORS) is not currently exercised

ServeCachedMedia, hasScheme, and normalizeHTTPMediaURL are covered, but ProxyMedia’s key behaviors remain untested: 404 when `source == ""`, OPTIONS/CORS with 204, rejection of non-GET/HEAD methods, serving local files, and proxying remote URLs with CORS headers. Please add httptest-based tests for at least the OPTIONS path, a local file source, and a simple HTTP upstream (e.g., via httptest.Server) to validate the new streaming behavior end-to-end.

Suggested implementation:

```golang
package cast

import (
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"testing"
)

func TestServeCachedMediaSupportsRanges(t *testing.T) {
	f, err := os.CreateTemp(t.TempDir(), "media-*")
	if err != nil {
		t.Fatal(err)
	}
	if _, err := f.WriteString("abcdef"); err != nil {
		t.Fatal(err)
	}
	if err := f.Close(); err != nil {
		t.Fatal(err)
	}

	req := httptest.NewRequest(http.MethodGet, "/stream", nil)
	req.Header.Set("Range", "bytes=2-4")

	rr := httptest.NewRecorder()
	ServeCachedMedia(rr, req, f.Name())

	res := rr.Result()
	defer res.Body.Close()

	if res.StatusCode != http.StatusPartialContent {
		t.Fatalf("expected status %d, got %d", http.StatusPartialContent, res.StatusCode)
	}

	got, err := io.ReadAll(res.Body)
	if err != nil {
		t.Fatal(err)
	}
	if string(got) != "cde" {
		t.Fatalf("expected body %q, got %q", "cde", string(got))
	}
}

func TestProxyMediaEmptySourceReturnsNotFound(t *testing.T) {
	req := httptest.NewRequest(http.MethodGet, "/proxy", nil)
	rr := httptest.NewRecorder()

	ProxyMedia(rr, req, "")

	res := rr.Result()
	defer res.Body.Close()

	if res.StatusCode != http.StatusNotFound {
		t.Fatalf("expected status %d for empty source, got %d", http.StatusNotFound, res.StatusCode)
	}
}

func TestProxyMediaRejectsNonGETOrHEAD(t *testing.T) {
	for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
		t.Run(method, func(t *testing.T) {
			req := httptest.NewRequest(method, "/proxy", nil)
			rr := httptest.NewRecorder()

			ProxyMedia(rr, req, "/does/not/matter")

			res := rr.Result()
			defer res.Body.Close()

			if res.StatusCode != http.StatusMethodNotAllowed {
				t.Fatalf("expected status %d for method %s, got %d", http.StatusMethodNotAllowed, method, res.StatusCode)
			}
		})
	}
}

func TestProxyMediaOptionsReturnsNoContentWithCORS(t *testing.T) {
	req := httptest.NewRequest(http.MethodOptions, "/proxy", nil)
	req.Header.Set("Origin", "http://example.com")

	rr := httptest.NewRecorder()
	ProxyMedia(rr, req, "/ignored-for-options")

	res := rr.Result()
	defer res.Body.Close()

	if res.StatusCode != http.StatusNoContent {
		t.Fatalf("expected status %d for OPTIONS, got %d", http.StatusNoContent, res.StatusCode)
	}

	allowOrigin := res.Header.Get("Access-Control-Allow-Origin")
	if allowOrigin == "" {
		t.Fatalf("expected Access-Control-Allow-Origin header to be set")
	}

	if res.Header.Get("Access-Control-Allow-Methods") == "" {
		t.Fatalf("expected Access-Control-Allow-Methods header to be set")
	}
}

func TestProxyMediaServesLocalFile(t *testing.T) {
	f, err := os.CreateTemp(t.TempDir(), "proxy-local-*")
	if err != nil {
		t.Fatal(err)
	}
	const content = "local-file-content"
	if _, err := f.WriteString(content); err != nil {
		t.Fatal(err)
	}
	if err := f.Close(); err != nil {
		t.Fatal(err)
	}

	req := httptest.NewRequest(http.MethodGet, "/proxy", nil)
	rr := httptest.NewRecorder()

	ProxyMedia(rr, req, f.Name())

	res := rr.Result()
	defer res.Body.Close()

	if res.StatusCode != http.StatusOK {
		t.Fatalf("expected status %d for local file, got %d", http.StatusOK, res.StatusCode)
	}

	body, err := io.ReadAll(res.Body)
	if err != nil {
		t.Fatal(err)
	}
	if string(body) != content {
		t.Fatalf("expected body %q, got %q", content, string(body))
	}

	if res.Header.Get("Access-Control-Allow-Origin") == "" {
		t.Fatalf("expected CORS headers on local file response")
	}
}

func TestProxyMediaProxiesRemoteURLWithCORS(t *testing.T) {
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/plain")
		_, _ = io.WriteString(w, "upstream-body")
	}))
	defer upstream.Close()

	u, err := url.Parse(upstream.URL)
	if err != nil {
		t.Fatal(err)
	}

	req := httptest.NewRequest(http.MethodGet, "/proxy", nil)
	req.Header.Set("Origin", "http://example-client")
	rr := httptest.NewRecorder()

	ProxyMedia(rr, req, u.String())

	res := rr.Result()
	defer res.Body.Close()

	if res.StatusCode != http.StatusOK {
		t.Fatalf("expected status %d for remote URL, got %d", http.StatusOK, res.StatusCode)
	}

	body, err := io.ReadAll(res.Body)
	if err != nil {
		t.Fatal(err)
	}
	if string(body) != "upstream-body" {
		t.Fatalf("expected proxied body %q, got %q", "upstream-body", string(body))
	}

	if res.Header.Get("Access-Control-Allow-Origin") == "" {
		t.Fatalf("expected CORS headers on proxied response")
	}
}

```

The above tests assume the following signatures and behaviors, which you may need to align with your existing implementation:

1. `ServeCachedMedia(w http.ResponseWriter, r *http.Request, path string)`:
   - Supports HTTP Range requests on a local file at `path`, returning `StatusPartialContent` and the requested byte range. If the API differs, adjust the call and assertions accordingly.

2. `ProxyMedia(w http.ResponseWriter, r *http.Request, source string)`:
   - Returns `StatusNotFound` when `source == ""`.
   - Returns `StatusMethodNotAllowed` for non-GET/HEAD methods.
   - Handles `OPTIONS` requests with `StatusNoContent` and appropriate CORS headers (e.g., `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods`).
   - When `source` is a local file path, streams its contents with `StatusOK` and applies CORS headers.
   - When `source` is an HTTP/HTTPS URL, proxies the upstream response body with `StatusOK` and applies CORS headers.

If your CORS implementation uses specific values (such as `"*"` for `Access-Control-Allow-Origin` or a fixed method list), update the header assertions to match those values. Also ensure the import list matches your project conventions (remove `io` or `net/url` if they are unused after aligning with the real functions).
</issue_to_address>

### Comment 4
<location path="cast/server_test.go" line_range="5-8" />
<code_context>
+
+import "testing"
+
+func TestSourceIDForNamespaceUsesAppTransportForMedia(t *testing.T) {
+	if got := sourceIDForNamespace(nsMedia); got != defaultTransport {
+		t.Fatalf("sourceIDForNamespace(nsMedia) = %q, want %q", got, defaultTransport)
+	}
+}
+
</code_context>
<issue_to_address>
**suggestion (testing):** Session send/sendRaw behavior could be tested to confirm source/destination IDs and logging changes

These tests cover sourceIDForNamespace, but the higher-level Session APIs (send, sendRaw, rememberSender, senderDestination) remain untested. Given the routing changes, please also add tests that:
- Create a Session with a fake connection and assert send() routes via defaultTransport for nsMedia and defaultReceiverID for other namespaces.
- Exercise rememberSender/senderDestination and confirm sendRaw targets the remembered sender rather than the receiver.
This will verify the new Cast routing behavior end-to-end.

Suggested implementation:

```golang
package cast

import "testing"

func TestSourceIDForNamespaceUsesAppTransportForMedia(t *testing.T) {
	if got := sourceIDForNamespace(nsMedia); got != defaultTransport {
		t.Fatalf("sourceIDForNamespace(nsMedia) = %q, want %q", got, defaultTransport)
	}
}

// fakeConn is a test double for the underlying connection used by Session.
// It records the last message sent so tests can inspect source/destination IDs.
type fakeConn struct {
	lastNamespace   string
	lastSourceID    string
	lastDestination string
	lastPayload     []byte
}

func (c *fakeConn) Send(namespace, sourceID, destination string, payload []byte) error {
	c.lastNamespace = namespace
	c.lastSourceID = sourceID
	c.lastDestination = destination
	c.lastPayload = payload
	return nil
}

func TestSessionSendRoutesMediaViaDefaultTransport(t *testing.T) {
	conn := &fakeConn{}

	// NOTE: Adjust this constructor call to match the actual Session initializer.
	// For example, if it is NewSession(conn) or NewSessionWithDefaults(conn, defaultReceiverID, defaultTransport).
	s := &Session{
		conn:             conn,
		defaultReceiver:  defaultReceiverID,
		defaultTransport: defaultTransport,
	}

	payload := []byte(`{"type":"MEDIA_STATUS"}`)

	// Media namespace should use defaultTransport as source and defaultReceiverID as destination.
	if err := s.send(nsMedia, payload); err != nil {
		t.Fatalf("send(nsMedia) error = %v, want nil", err)
	}

	if conn.lastNamespace != nsMedia {
		t.Fatalf("send(nsMedia) namespace = %q, want %q", conn.lastNamespace, nsMedia)
	}
	if conn.lastSourceID != defaultTransport {
		t.Fatalf("send(nsMedia) sourceID = %q, want %q", conn.lastSourceID, defaultTransport)
	}
	if conn.lastDestination != defaultReceiverID {
		t.Fatalf("send(nsMedia) destination = %q, want %q", conn.lastDestination, defaultReceiverID)
	}

	// Non-media namespaces should use the receiver as both source and destination.
	if err := s.send(nsReceiver, payload); err != nil {
		t.Fatalf("send(nsReceiver) error = %v, want nil", err)
	}

	if conn.lastNamespace != nsReceiver {
		t.Fatalf("send(nsReceiver) namespace = %q, want %q", conn.lastNamespace, nsReceiver)
	}
	if conn.lastSourceID != defaultReceiverID {
		t.Fatalf("send(nsReceiver) sourceID = %q, want %q", conn.lastSourceID, defaultReceiverID)
	}
	if conn.lastDestination != defaultReceiverID {
		t.Fatalf("send(nsReceiver) destination = %q, want %q", conn.lastDestination, defaultReceiverID)
	}
}

func TestSessionSendRawUsesRememberedSenderDestination(t *testing.T) {
	conn := &fakeConn{}

	// NOTE: Adjust this constructor call to match the actual Session initializer.
	s := &Session{
		conn:             conn,
		defaultReceiver:  defaultReceiverID,
		defaultTransport: defaultTransport,
	}

	// Remember a sender (e.g., an app transport) as the destination for subsequent sendRaw calls.
	appSenderID := "sender-123"
	s.rememberSender(appSenderID)

	payload := []byte(`{"type":"CUSTOM"}`)

	// sendRaw should route to the remembered sender, not the receiver.
	if err := s.sendRaw(nsMedia, payload); err != nil {
		t.Fatalf("sendRaw(nsMedia) error = %v, want nil", err)
	}

	if conn.lastNamespace != nsMedia {
		t.Fatalf("sendRaw(nsMedia) namespace = %q, want %q", conn.lastNamespace, nsMedia)
	}

	// Source should typically remain the transport for media.
	if conn.lastSourceID != defaultTransport {
		t.Fatalf("sendRaw(nsMedia) sourceID = %q, want %q", conn.lastSourceID, defaultTransport)
	}

	// Destination should be the remembered sender, not the receiver.
	if conn.lastDestination != appSenderID {
		t.Fatalf("sendRaw(nsMedia) destination = %q, want %q", conn.lastDestination, appSenderID)
	}

	// Verify senderDestination accessor reflects the remembered sender.
	if got := s.senderDestination(); got != appSenderID {
		t.Fatalf("senderDestination() = %q, want %q", got, appSenderID)
	}
}

func TestSourceIDForNamespaceUsesReceiverForReceiverNamespaces(t *testing.T) {
	for _, ns := range []string{nsReceiver, nsHeartbeat, nsConnection} {

```

1. Align the `fakeConn` test double with the actual connection interface/struct used in `Session`. If the `Session` writes protocol messages via a different method signature (e.g. `Send(*Message)`), adjust `fakeConn` accordingly and update the tests to capture `sourceID`/`destination` from the real message type.
2. Replace the `Session` construction in the tests with the real initializer. For example, if there is a `NewSession(conn)` function or fields are unexported, use the appropriate constructor and remove direct field assignment.
3. Ensure the `Session.send`, `Session.sendRaw`, `Session.rememberSender`, and `Session.senderDestination` signatures match the calls in the tests. If their parameter lists differ (e.g. they accept a context, message type, or additional flags), update the tests to pass the required arguments.
4. If routing logic around `defaultTransport`, `defaultReceiverID`, and media namespaces behaves slightly differently (e.g. additional namespaces, different defaults), adjust the test expectations (`conn.lastSourceID` and `conn.lastDestination` checks) to match the actual routing rules.
5. If additional imports (such as `context` or other packages) are needed for the real `Session` constructor or message type, add them to the import block and ensure there is still only one consolidated `import` section.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread cast/receiver.go
Comment on lines 74 to +76
Media: &MediaSession{
PlayerState: "IDLE",
PlayerState: "IDLE",
PlaybackRate: 1,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Initial PlaybackRate is set to 1 while PlayerState is IDLE, and dashboard controls don’t keep PlaybackRate in sync with state.

NewReceiver sets Media.PlaybackRate to 1 while PlayerState is "IDLE", but playbackRateForState returns 0 for non-playing states. The dashboard controlHandler mutates PlayerState without updating PlaybackRate, so PLAY/PAUSE/STOP actions can leave PlaybackRate inconsistent with PlayerState. Please initialize PlaybackRate to 0 for IDLE and update it via playbackRateForState whenever PlayerState changes (including in dashboard.go) to keep media status consistent.

Comment thread cast/httpproxy.go
return
}
if r.Method == http.MethodOptions {
writeProxyCORS(w)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 issue (security): Serving arbitrary local file paths via ProxyMedia can expose unexpected files or paths to the dashboard origin.

Because any source without a scheme is treated as a local path and passed directly to http.ServeFile if os.Stat succeeds, a sender-controlled contentId like "../../somefile" could trigger path traversal and expose arbitrary local files. Please constrain local file access to a specific base directory (or known cache location), normalize and validate paths, and reject requests that resolve outside that directory to prevent unintended file exposure.

Comment thread cast/httpproxy_test.go
Comment on lines +11 to +15
func TestServeCachedMediaSupportsRanges(t *testing.T) {
f, err := os.CreateTemp(t.TempDir(), "media-*")
if err != nil {
t.Fatal(err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): ProxyMedia behavior (local file vs remote URL, method handling, OPTIONS/CORS) is not currently exercised

ServeCachedMedia, hasScheme, and normalizeHTTPMediaURL are covered, but ProxyMedia’s key behaviors remain untested: 404 when source == "", OPTIONS/CORS with 204, rejection of non-GET/HEAD methods, serving local files, and proxying remote URLs with CORS headers. Please add httptest-based tests for at least the OPTIONS path, a local file source, and a simple HTTP upstream (e.g., via httptest.Server) to validate the new streaming behavior end-to-end.

Suggested implementation:

package cast

import (
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"testing"
)

func TestServeCachedMediaSupportsRanges(t *testing.T) {
	f, err := os.CreateTemp(t.TempDir(), "media-*")
	if err != nil {
		t.Fatal(err)
	}
	if _, err := f.WriteString("abcdef"); err != nil {
		t.Fatal(err)
	}
	if err := f.Close(); err != nil {
		t.Fatal(err)
	}

	req := httptest.NewRequest(http.MethodGet, "/stream", nil)
	req.Header.Set("Range", "bytes=2-4")

	rr := httptest.NewRecorder()
	ServeCachedMedia(rr, req, f.Name())

	res := rr.Result()
	defer res.Body.Close()

	if res.StatusCode != http.StatusPartialContent {
		t.Fatalf("expected status %d, got %d", http.StatusPartialContent, res.StatusCode)
	}

	got, err := io.ReadAll(res.Body)
	if err != nil {
		t.Fatal(err)
	}
	if string(got) != "cde" {
		t.Fatalf("expected body %q, got %q", "cde", string(got))
	}
}

func TestProxyMediaEmptySourceReturnsNotFound(t *testing.T) {
	req := httptest.NewRequest(http.MethodGet, "/proxy", nil)
	rr := httptest.NewRecorder()

	ProxyMedia(rr, req, "")

	res := rr.Result()
	defer res.Body.Close()

	if res.StatusCode != http.StatusNotFound {
		t.Fatalf("expected status %d for empty source, got %d", http.StatusNotFound, res.StatusCode)
	}
}

func TestProxyMediaRejectsNonGETOrHEAD(t *testing.T) {
	for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
		t.Run(method, func(t *testing.T) {
			req := httptest.NewRequest(method, "/proxy", nil)
			rr := httptest.NewRecorder()

			ProxyMedia(rr, req, "/does/not/matter")

			res := rr.Result()
			defer res.Body.Close()

			if res.StatusCode != http.StatusMethodNotAllowed {
				t.Fatalf("expected status %d for method %s, got %d", http.StatusMethodNotAllowed, method, res.StatusCode)
			}
		})
	}
}

func TestProxyMediaOptionsReturnsNoContentWithCORS(t *testing.T) {
	req := httptest.NewRequest(http.MethodOptions, "/proxy", nil)
	req.Header.Set("Origin", "http://example.com")

	rr := httptest.NewRecorder()
	ProxyMedia(rr, req, "/ignored-for-options")

	res := rr.Result()
	defer res.Body.Close()

	if res.StatusCode != http.StatusNoContent {
		t.Fatalf("expected status %d for OPTIONS, got %d", http.StatusNoContent, res.StatusCode)
	}

	allowOrigin := res.Header.Get("Access-Control-Allow-Origin")
	if allowOrigin == "" {
		t.Fatalf("expected Access-Control-Allow-Origin header to be set")
	}

	if res.Header.Get("Access-Control-Allow-Methods") == "" {
		t.Fatalf("expected Access-Control-Allow-Methods header to be set")
	}
}

func TestProxyMediaServesLocalFile(t *testing.T) {
	f, err := os.CreateTemp(t.TempDir(), "proxy-local-*")
	if err != nil {
		t.Fatal(err)
	}
	const content = "local-file-content"
	if _, err := f.WriteString(content); err != nil {
		t.Fatal(err)
	}
	if err := f.Close(); err != nil {
		t.Fatal(err)
	}

	req := httptest.NewRequest(http.MethodGet, "/proxy", nil)
	rr := httptest.NewRecorder()

	ProxyMedia(rr, req, f.Name())

	res := rr.Result()
	defer res.Body.Close()

	if res.StatusCode != http.StatusOK {
		t.Fatalf("expected status %d for local file, got %d", http.StatusOK, res.StatusCode)
	}

	body, err := io.ReadAll(res.Body)
	if err != nil {
		t.Fatal(err)
	}
	if string(body) != content {
		t.Fatalf("expected body %q, got %q", content, string(body))
	}

	if res.Header.Get("Access-Control-Allow-Origin") == "" {
		t.Fatalf("expected CORS headers on local file response")
	}
}

func TestProxyMediaProxiesRemoteURLWithCORS(t *testing.T) {
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/plain")
		_, _ = io.WriteString(w, "upstream-body")
	}))
	defer upstream.Close()

	u, err := url.Parse(upstream.URL)
	if err != nil {
		t.Fatal(err)
	}

	req := httptest.NewRequest(http.MethodGet, "/proxy", nil)
	req.Header.Set("Origin", "http://example-client")
	rr := httptest.NewRecorder()

	ProxyMedia(rr, req, u.String())

	res := rr.Result()
	defer res.Body.Close()

	if res.StatusCode != http.StatusOK {
		t.Fatalf("expected status %d for remote URL, got %d", http.StatusOK, res.StatusCode)
	}

	body, err := io.ReadAll(res.Body)
	if err != nil {
		t.Fatal(err)
	}
	if string(body) != "upstream-body" {
		t.Fatalf("expected proxied body %q, got %q", "upstream-body", string(body))
	}

	if res.Header.Get("Access-Control-Allow-Origin") == "" {
		t.Fatalf("expected CORS headers on proxied response")
	}
}

The above tests assume the following signatures and behaviors, which you may need to align with your existing implementation:

  1. ServeCachedMedia(w http.ResponseWriter, r *http.Request, path string):

    • Supports HTTP Range requests on a local file at path, returning StatusPartialContent and the requested byte range. If the API differs, adjust the call and assertions accordingly.
  2. ProxyMedia(w http.ResponseWriter, r *http.Request, source string):

    • Returns StatusNotFound when source == "".
    • Returns StatusMethodNotAllowed for non-GET/HEAD methods.
    • Handles OPTIONS requests with StatusNoContent and appropriate CORS headers (e.g., Access-Control-Allow-Origin, Access-Control-Allow-Methods).
    • When source is a local file path, streams its contents with StatusOK and applies CORS headers.
    • When source is an HTTP/HTTPS URL, proxies the upstream response body with StatusOK and applies CORS headers.

If your CORS implementation uses specific values (such as "*" for Access-Control-Allow-Origin or a fixed method list), update the header assertions to match those values. Also ensure the import list matches your project conventions (remove io or net/url if they are unused after aligning with the real functions).

Comment thread cast/server_test.go
Comment on lines +5 to +8
func TestSourceIDForNamespaceUsesAppTransportForMedia(t *testing.T) {
if got := sourceIDForNamespace(nsMedia); got != defaultTransport {
t.Fatalf("sourceIDForNamespace(nsMedia) = %q, want %q", got, defaultTransport)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Session send/sendRaw behavior could be tested to confirm source/destination IDs and logging changes

These tests cover sourceIDForNamespace, but the higher-level Session APIs (send, sendRaw, rememberSender, senderDestination) remain untested. Given the routing changes, please also add tests that:

  • Create a Session with a fake connection and assert send() routes via defaultTransport for nsMedia and defaultReceiverID for other namespaces.
  • Exercise rememberSender/senderDestination and confirm sendRaw targets the remembered sender rather than the receiver.
    This will verify the new Cast routing behavior end-to-end.

Suggested implementation:

package cast

import "testing"

func TestSourceIDForNamespaceUsesAppTransportForMedia(t *testing.T) {
	if got := sourceIDForNamespace(nsMedia); got != defaultTransport {
		t.Fatalf("sourceIDForNamespace(nsMedia) = %q, want %q", got, defaultTransport)
	}
}

// fakeConn is a test double for the underlying connection used by Session.
// It records the last message sent so tests can inspect source/destination IDs.
type fakeConn struct {
	lastNamespace   string
	lastSourceID    string
	lastDestination string
	lastPayload     []byte
}

func (c *fakeConn) Send(namespace, sourceID, destination string, payload []byte) error {
	c.lastNamespace = namespace
	c.lastSourceID = sourceID
	c.lastDestination = destination
	c.lastPayload = payload
	return nil
}

func TestSessionSendRoutesMediaViaDefaultTransport(t *testing.T) {
	conn := &fakeConn{}

	// NOTE: Adjust this constructor call to match the actual Session initializer.
	// For example, if it is NewSession(conn) or NewSessionWithDefaults(conn, defaultReceiverID, defaultTransport).
	s := &Session{
		conn:             conn,
		defaultReceiver:  defaultReceiverID,
		defaultTransport: defaultTransport,
	}

	payload := []byte(`{"type":"MEDIA_STATUS"}`)

	// Media namespace should use defaultTransport as source and defaultReceiverID as destination.
	if err := s.send(nsMedia, payload); err != nil {
		t.Fatalf("send(nsMedia) error = %v, want nil", err)
	}

	if conn.lastNamespace != nsMedia {
		t.Fatalf("send(nsMedia) namespace = %q, want %q", conn.lastNamespace, nsMedia)
	}
	if conn.lastSourceID != defaultTransport {
		t.Fatalf("send(nsMedia) sourceID = %q, want %q", conn.lastSourceID, defaultTransport)
	}
	if conn.lastDestination != defaultReceiverID {
		t.Fatalf("send(nsMedia) destination = %q, want %q", conn.lastDestination, defaultReceiverID)
	}

	// Non-media namespaces should use the receiver as both source and destination.
	if err := s.send(nsReceiver, payload); err != nil {
		t.Fatalf("send(nsReceiver) error = %v, want nil", err)
	}

	if conn.lastNamespace != nsReceiver {
		t.Fatalf("send(nsReceiver) namespace = %q, want %q", conn.lastNamespace, nsReceiver)
	}
	if conn.lastSourceID != defaultReceiverID {
		t.Fatalf("send(nsReceiver) sourceID = %q, want %q", conn.lastSourceID, defaultReceiverID)
	}
	if conn.lastDestination != defaultReceiverID {
		t.Fatalf("send(nsReceiver) destination = %q, want %q", conn.lastDestination, defaultReceiverID)
	}
}

func TestSessionSendRawUsesRememberedSenderDestination(t *testing.T) {
	conn := &fakeConn{}

	// NOTE: Adjust this constructor call to match the actual Session initializer.
	s := &Session{
		conn:             conn,
		defaultReceiver:  defaultReceiverID,
		defaultTransport: defaultTransport,
	}

	// Remember a sender (e.g., an app transport) as the destination for subsequent sendRaw calls.
	appSenderID := "sender-123"
	s.rememberSender(appSenderID)

	payload := []byte(`{"type":"CUSTOM"}`)

	// sendRaw should route to the remembered sender, not the receiver.
	if err := s.sendRaw(nsMedia, payload); err != nil {
		t.Fatalf("sendRaw(nsMedia) error = %v, want nil", err)
	}

	if conn.lastNamespace != nsMedia {
		t.Fatalf("sendRaw(nsMedia) namespace = %q, want %q", conn.lastNamespace, nsMedia)
	}

	// Source should typically remain the transport for media.
	if conn.lastSourceID != defaultTransport {
		t.Fatalf("sendRaw(nsMedia) sourceID = %q, want %q", conn.lastSourceID, defaultTransport)
	}

	// Destination should be the remembered sender, not the receiver.
	if conn.lastDestination != appSenderID {
		t.Fatalf("sendRaw(nsMedia) destination = %q, want %q", conn.lastDestination, appSenderID)
	}

	// Verify senderDestination accessor reflects the remembered sender.
	if got := s.senderDestination(); got != appSenderID {
		t.Fatalf("senderDestination() = %q, want %q", got, appSenderID)
	}
}

func TestSourceIDForNamespaceUsesReceiverForReceiverNamespaces(t *testing.T) {
	for _, ns := range []string{nsReceiver, nsHeartbeat, nsConnection} {
  1. Align the fakeConn test double with the actual connection interface/struct used in Session. If the Session writes protocol messages via a different method signature (e.g. Send(*Message)), adjust fakeConn accordingly and update the tests to capture sourceID/destination from the real message type.
  2. Replace the Session construction in the tests with the real initializer. For example, if there is a NewSession(conn) function or fields are unexported, use the appropriate constructor and remove direct field assignment.
  3. Ensure the Session.send, Session.sendRaw, Session.rememberSender, and Session.senderDestination signatures match the calls in the tests. If their parameter lists differ (e.g. they accept a context, message type, or additional flags), update the tests to pass the required arguments.
  4. If routing logic around defaultTransport, defaultReceiverID, and media namespaces behaves slightly differently (e.g. additional namespaces, different defaults), adjust the test expectations (conn.lastSourceID and conn.lastDestination checks) to match the actual routing rules.
  5. If additional imports (such as context or other packages) are needed for the real Session constructor or message type, add them to the import block and ensure there is still only one consolidated import section.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant