diff --git a/cmd/soundtouch-cli/cmd_setup.go b/cmd/soundtouch-cli/cmd_setup.go index 023918f..2c71ea5 100644 --- a/cmd/soundtouch-cli/cmd_setup.go +++ b/cmd/soundtouch-cli/cmd_setup.go @@ -921,21 +921,6 @@ func renderMigrationSummary(deviceIP, serviceURL string, s *setup.MigrationSumma fmt.Printf(" %s resolv (/etc/resolv.conf via DHCP hook)\n", checkmark(s.ResolvMigrated)) fmt.Println() - if s.MirrorEnabled || len(s.MirrorEndpoints) > 0 { - fmt.Println("Mirroring") - fmt.Printf(" %s enabled\n", checkmark(s.MirrorEnabled)) - - if len(s.MirrorEndpoints) > 0 { - fmt.Printf(" endpoints: %s\n", strings.Join(s.MirrorEndpoints, ", ")) - } - - if len(s.SkipMirrorEndpoints) > 0 { - fmt.Printf(" skip: %s\n", strings.Join(s.SkipMirrorEndpoints, ", ")) - } - - fmt.Println() - } - if len(s.Warnings) > 0 { fmt.Println("Warnings") diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index a952c39..75fab3e 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -337,21 +337,6 @@ func main() { Usage: "External base URL for OAuth callbacks behind reverse proxy", EnvVars: []string{"BASE_URL"}, }, - &cli.BoolFlag{ - Name: "mirror-enabled", - Usage: "Enable background mirroring to Bose Cloud", - EnvVars: []string{"MIRROR_ENABLED"}, - }, - &cli.StringSliceFlag{ - Name: "mirror-endpoints", - Usage: "Endpoints to mirror to Bose Cloud (comma-separated or multiple flags)", - EnvVars: []string{"MIRROR_ENDPOINTS"}, - }, - &cli.StringSliceFlag{ - Name: "skip-mirror-endpoints", - Usage: "Endpoints to skip mirroring to Bose Cloud (comma-separated or multiple flags)", - EnvVars: []string{"SKIP_MIRROR_ENDPOINTS"}, - }, &cli.StringSliceFlag{ Name: "internal-paths", Usage: "Paths for internal requests (comma-separated or multiple flags)", @@ -368,12 +353,6 @@ func main() { Usage: "Log what would be migrated without actually doing it", EnvVars: []string{"MIGRATION_DRY_RUN"}, }, - &cli.StringFlag{ - Name: "preferred-source", - Usage: "Preferred source of truth (local or upstream)", - Value: "local", - EnvVars: []string{"PREFERRED_SOURCE"}, - }, &cli.StringFlag{ Name: "stockholm-dir", Usage: "Path to the extracted Stockholm frontend directory (enables Stockholm UI when set)", @@ -415,7 +394,6 @@ func main() { server.SetVersionInfo(version, commit, date, repoURL) server.SetDiscoverySettings(config.discoveryInterval, config.discoveryEnabled) server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr) - server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints, persisted.SkipMirrorEndpoints, persisted.PreferredSource) server.SetInternalPaths(persisted.InternalPaths) server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI) server.SetAmazonConfig(config.amazonClientID, config.amazonClientSecret, config.amazonRedirectURI) @@ -555,9 +533,6 @@ type serviceConfig struct { dnsEnabled bool dnsUpstream string dnsBind string - mirrorEnabled bool - mirrorEndpoints []string - skipMirrorEndpoints []string internalPaths []string discoveryEnabled bool discoveryInterval time.Duration @@ -576,7 +551,6 @@ type serviceConfig struct { mgmtPassword string migrationEnabled bool migrationDryRun bool - preferredSource string stockholmDir string stockholmBasePath string } @@ -648,13 +622,9 @@ func loadConfig(c *cli.Context) serviceConfig { amazonProfileURL := c.String("amazon-profile-url") mgmtUsername := c.String("mgmt-username") mgmtPassword := c.String("mgmt-password") - mirrorEnabled := c.Bool("mirror-enabled") - mirrorEndpoints := c.StringSlice("mirror-endpoints") - skipMirrorEndpoints := c.StringSlice("skip-mirror-endpoints") internalPaths := c.StringSlice("internal-paths") migrationEnabled := c.Bool("migration-enabled") migrationDryRun := c.Bool("migration-dry-run") - preferredSource := c.String("preferred-source") stockholmDir := c.String("stockholm-dir") stockholmBasePath := c.String("stockholm-base-path") @@ -673,9 +643,6 @@ func loadConfig(c *cli.Context) serviceConfig { dnsEnabled: dnsEnabled, dnsUpstream: dnsUpstream, dnsBind: dnsBind, - mirrorEnabled: mirrorEnabled, - mirrorEndpoints: mirrorEndpoints, - skipMirrorEndpoints: skipMirrorEndpoints, internalPaths: internalPaths, discoveryEnabled: discoveryEnabled, discoveryInterval: discoveryInterval, @@ -694,7 +661,6 @@ func loadConfig(c *cli.Context) serviceConfig { mgmtPassword: mgmtPassword, migrationEnabled: migrationEnabled, migrationDryRun: migrationDryRun, - preferredSource: preferredSource, stockholmDir: stockholmDir, stockholmBasePath: stockholmBasePath, } @@ -782,10 +748,6 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data config.dnsBind = persisted.DNSBindAddr } - config.mirrorEnabled = persisted.MirrorEnabled - config.mirrorEndpoints = persisted.MirrorEndpoints - config.skipMirrorEndpoints = persisted.SkipMirrorEndpoints - config.preferredSource = persisted.PreferredSource config.internalPaths = persisted.InternalPaths // CLI/env args take precedence; only apply persisted credentials when not set via CLI. @@ -824,21 +786,17 @@ func applyPersistedMusicServiceCredentials(config *serviceConfig, persisted data func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings { settings := datastore.Settings{ - ServerURL: config.serverURL, - HTTPServerURL: config.httpsServerURL, - RedactLogs: config.redact, - LogBodies: config.logBody, - RecordInteractions: config.record, - DiscoveryEnabled: config.discoveryEnabled, - DiscoveryInterval: config.discoveryInterval.String(), - DNSEnabled: config.dnsEnabled, - DNSUpstream: strings.Split(config.dnsUpstream, ","), - DNSBindAddr: config.dnsBind, - MirrorEnabled: config.mirrorEnabled, - MirrorEndpoints: config.mirrorEndpoints, - SkipMirrorEndpoints: config.skipMirrorEndpoints, - PreferredSource: config.preferredSource, - InternalPaths: config.internalPaths, + ServerURL: config.serverURL, + HTTPServerURL: config.httpsServerURL, + RedactLogs: config.redact, + LogBodies: config.logBody, + RecordInteractions: config.record, + DiscoveryEnabled: config.discoveryEnabled, + DiscoveryInterval: config.discoveryInterval.String(), + DNSEnabled: config.dnsEnabled, + DNSUpstream: strings.Split(config.dnsUpstream, ","), + DNSBindAddr: config.dnsBind, + InternalPaths: config.internalPaths, Shortcuts: map[string]int{ "/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound, "/sw.js": http.StatusNotFound, @@ -900,7 +858,6 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) * r.Use(middleware.Recoverer) r.Use(server.PeerObserverMiddleware) r.Use(server.ShortcutMiddleware) - r.Use(server.MirrorMiddleware) r.Use(server.RecordMiddleware) r.Get("/", server.HandleRoot) @@ -1173,8 +1130,6 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) * r.Get("/interaction-stats", server.HandleGetInteractionStats) r.Get("/interactions", server.HandleListInteractions) r.Get("/interaction-content", server.HandleGetInteractionContent) - r.Get("/parity-mismatches", server.HandleListParityMismatches) - r.Delete("/parity-mismatches", server.HandleClearParityMismatches) r.Get("/interactions/sessions/{session}/download", server.HandleDownloadSession) r.Delete("/interactions/sessions/{session}", server.HandleDeleteSession) r.Delete("/interactions/sessions", server.HandleCleanupSessions) diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt index e7b055c..68baf17 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -11,7 +11,6 @@ DELETE /setup/devices/{deviceId} handlers.( DELETE /setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm DELETE /setup/interactions/sessions handlers.(*Server).HandleCleanupSessions-fm DELETE /setup/interactions/sessions/{session} handlers.(*Server).HandleDeleteSession-fm -DELETE /setup/parity-mismatches handlers.(*Server).HandleClearParityMismatches-fm DELETE /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeRemoveDevice-fm DELETE /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeRemovePreset-fm DELETE /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm @@ -68,7 +67,6 @@ GET /setup/interaction-content handlers.( GET /setup/interaction-stats handlers.(*Server).HandleGetInteractionStats-fm GET /setup/interactions handlers.(*Server).HandleListInteractions-fm GET /setup/interactions/sessions/{session}/download handlers.(*Server).HandleDownloadSession-fm -GET /setup/parity-mismatches handlers.(*Server).HandleListParityMismatches-fm GET /setup/proxy-settings handlers.(*Server).HandleGetProxySettings-fm GET /setup/settings handlers.(*Server).HandleGetSettings-fm GET /setup/summary/{deviceId} handlers.(*Server).HandleGetMigrationSummary-fm diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index 96f128c..ddb87a3 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -2093,10 +2093,6 @@ type Settings struct { DNSEnabled bool `json:"dns_enabled"` DNSUpstream []string `json:"dns_upstream,omitempty"` DNSBindAddr string `json:"dns_bind_addr,omitempty"` - MirrorEnabled bool `json:"mirror_enabled"` - MirrorEndpoints []string `json:"mirror_endpoints,omitempty"` - SkipMirrorEndpoints []string `json:"skip_mirror_endpoints,omitempty"` - PreferredSource string `json:"preferred_source,omitempty"` InternalPaths []string `json:"internal_paths,omitempty"` Shortcuts map[string]int `json:"shortcuts,omitempty"` SpotifyClientID string `json:"spotify_client_id,omitempty"` @@ -2107,7 +2103,7 @@ type Settings struct { AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"` // AllowInsecureUpstreamTLS, when true, disables TLS certificate verification - // for the upstream Bose-cloud proxy and mirror traffic. The default (false) + // for the upstream Bose-cloud proxy traffic. The default (false) // keeps verification on; opt in only when the upstream certificate chain is // broken (post end-of-service) and a temporary unblock is required. AllowInsecureUpstreamTLS bool `json:"allow_insecure_upstream_tls,omitempty"` diff --git a/pkg/service/handlers/handlers_bmx_report_test.go b/pkg/service/handlers/handlers_bmx_report_test.go index 0199c22..dbfdda5 100644 --- a/pkg/service/handlers/handlers_bmx_report_test.go +++ b/pkg/service/handlers/handlers_bmx_report_test.go @@ -9,8 +9,7 @@ import ( ) func TestHandleTuneInReport(t *testing.T) { - r, s := setupRouter("http://localhost:8001", nil) - s.SetMirrorSettings(false, nil, nil, "") + r, _ := setupRouter("http://localhost:8001", nil) ts := httptest.NewServer(r) defer ts.Close() diff --git a/pkg/service/handlers/handlers_bmx_test.go b/pkg/service/handlers/handlers_bmx_test.go index 79f3e6a..427b755 100644 --- a/pkg/service/handlers/handlers_bmx_test.go +++ b/pkg/service/handlers/handlers_bmx_test.go @@ -198,8 +198,7 @@ func TestBMXUnauthorized(t *testing.T) { } func TestHandleTuneInToken(t *testing.T) { - r, s := setupRouter("http://localhost:8001", nil) - s.SetMirrorSettings(false, nil, nil, "") + r, _ := setupRouter("http://localhost:8001", nil) ts := httptest.NewServer(r) defer ts.Close() @@ -229,8 +228,7 @@ func TestHandleTuneInToken(t *testing.T) { } func TestHandleTuneInPlayback_Authorized(t *testing.T) { - r, s := setupRouter("http://localhost:8001", nil) - s.SetMirrorSettings(false, nil, nil, "") + r, _ := setupRouter("http://localhost:8001", nil) ts := httptest.NewServer(r) defer ts.Close() diff --git a/pkg/service/handlers/handlers_oauth_test.go b/pkg/service/handlers/handlers_oauth_test.go index 3fca4ea..a5bcd7c 100644 --- a/pkg/service/handlers/handlers_oauth_test.go +++ b/pkg/service/handlers/handlers_oauth_test.go @@ -84,10 +84,6 @@ func TestHandleBoseSpotifyToken_FallbackToProxy(t *testing.T) { ds := datastore.NewDataStore(tmpDir) server := NewServer(ds, nil, "http://localhost", false, false, false) - // Mirroring must be enabled for HandleBoseProxy to work (based on previous changes) - // Actually I reverted that, so it should work regardless of MirrorEnabled now. - server.SetMirrorSettings(true, nil, nil, "") - // chi.URLParam works when using chi router r := chi.NewRouter() r.Post("/oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3", server.HandleBoseToken) @@ -260,7 +256,6 @@ func TestHandleBoseAmazonToken_FallbackToProxy(t *testing.T) { tmpDir := t.TempDir() ds := datastore.NewDataStore(tmpDir) server := NewServer(ds, nil, "http://localhost", false, false, false) - server.SetMirrorSettings(true, nil, nil, "") r := chi.NewRouter() r.Post("/oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs1", server.HandleBoseToken) diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go index cc564c4..f0c3b27 100644 --- a/pkg/service/handlers/handlers_setup.go +++ b/pkg/service/handlers/handlers_setup.go @@ -153,10 +153,6 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) { dnsEnabled := s.dnsEnabled dnsUpstream := s.dnsUpstream dnsBindAddr := s.dnsBindAddr - mirrorEnabled := s.mirrorEnabled - mirrorEndpoints := s.mirrorEndpoints - skipMirrorEndpoints := s.skipMirrorEndpoints - preferredSource := s.preferredSource internalPaths := s.internalPaths redact, logBody, record := s.proxyRedact, s.proxyLogBody, s.recordEnabled shortcuts := s.shortcuts @@ -211,10 +207,6 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) { "dns_actual_bind": actualBind, "dns_upstream": strings.Join(dnsUpstream, ","), "dns_bind_addr": dnsBindAddr, - "mirror_enabled": mirrorEnabled, - "mirror_endpoints": mirrorEndpoints, - "skip_mirror_endpoints": skipMirrorEndpoints, - "preferred_source": preferredSource, "internal_paths": internalPaths, "redact_logs": redact, "log_bodies": logBody, @@ -243,10 +235,6 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { DNSEnabled bool `json:"dns_enabled"` DNSUpstream string `json:"dns_upstream"` DNSBindAddr string `json:"dns_bind_addr"` - MirrorEnabled bool `json:"mirror_enabled"` - MirrorEndpoints []string `json:"mirror_endpoints"` - SkipMirrorEndpoints []string `json:"skip_mirror_endpoints"` - PreferredSource string `json:"preferred_source"` InternalPaths []string `json:"internal_paths"` Shortcuts map[string]int `json:"shortcuts"` SpotifyClientID string `json:"spotify_client_id"` @@ -311,10 +299,6 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { s.dnsUpstream = upstreamList s.dnsBindAddr = settings.DNSBindAddr - s.mirrorEnabled = settings.MirrorEnabled - s.mirrorEndpoints = settings.MirrorEndpoints - s.skipMirrorEndpoints = settings.SkipMirrorEndpoints - s.preferredSource = settings.PreferredSource s.internalPaths = settings.InternalPaths if settings.Shortcuts != nil { @@ -350,10 +334,6 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { DNSEnabled: s.dnsEnabled, DNSUpstream: s.dnsUpstream, DNSBindAddr: s.dnsBindAddr, - MirrorEnabled: s.mirrorEnabled, - MirrorEndpoints: s.mirrorEndpoints, - SkipMirrorEndpoints: s.skipMirrorEndpoints, - PreferredSource: s.preferredSource, InternalPaths: s.internalPaths, Shortcuts: s.shortcuts, SpotifyClientID: s.spotifyClientID, diff --git a/pkg/service/handlers/handlers_setup_test.go b/pkg/service/handlers/handlers_setup_test.go index e8b117f..c3c8e1e 100644 --- a/pkg/service/handlers/handlers_setup_test.go +++ b/pkg/service/handlers/handlers_setup_test.go @@ -126,49 +126,39 @@ func TestProxySettingsAPI(t *testing.T) { t.Errorf("POST /setup/settings: Server state did not update: serverURL=%s", sURL) } - // 4. Test Mirror Settings persistence - mirrorUpdate := map[string]interface{}{ - "server_url": "http://127.0.0.1:8000", - "mirror_enabled": true, - "mirror_endpoints": []string{"/test/*"}, - "internal_paths": []string{"/setup/*"}, + // 4. Test internal paths persistence + pathUpdate := map[string]interface{}{ + "server_url": "http://127.0.0.1:8000", + "internal_paths": []string{"/setup/*"}, } - mirrorBody, err := json.Marshal(mirrorUpdate) + pathBody, err := json.Marshal(pathUpdate) if err != nil { - t.Fatalf("Failed to marshal mirror settings: %v", err) + t.Fatalf("Failed to marshal path settings: %v", err) } - res, err = http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(mirrorBody)) + res, err = http.Post(ts.URL+"/setup/settings", "application/json", bytes.NewBuffer(pathBody)) if err != nil { t.Fatal(err) } res.Body.Close() if res.StatusCode != http.StatusOK { - t.Errorf("POST /setup/settings (mirror): Expected status OK, got %v", res.Status) + t.Errorf("POST /setup/settings (paths): Expected status OK, got %v", res.Status) } // Verify server state server.mu.RLock() - mEnabled := server.mirrorEnabled - mEndpoints := server.mirrorEndpoints iPaths := server.internalPaths server.mu.RUnlock() - if !mEnabled || len(mEndpoints) != 1 || mEndpoints[0] != "/test/*" { - t.Errorf("POST /setup/settings (mirror): Server state did not update: enabled=%v, endpoints=%v", mEnabled, mEndpoints) - } if len(iPaths) != 1 || iPaths[0] != "/setup/*" { - t.Errorf("POST /setup/settings (mirror): Internal paths did not update: %v", iPaths) + t.Errorf("POST /setup/settings (paths): Internal paths did not update: %v", iPaths) } // Verify persistence in datastore persisted, _ := ds.GetSettings() - if !persisted.MirrorEnabled || len(persisted.MirrorEndpoints) != 1 || persisted.MirrorEndpoints[0] != "/test/*" { - t.Errorf("POST /setup/settings (mirror): Datastore did not update: %+v", persisted) - } if len(persisted.InternalPaths) != 1 || persisted.InternalPaths[0] != "/setup/*" { - t.Errorf("POST /setup/settings (mirror): Datastore internal paths did not update: %+v", persisted) + t.Errorf("POST /setup/settings (paths): Datastore internal paths did not update: %+v", persisted) } } diff --git a/pkg/service/handlers/interactions_test.go b/pkg/service/handlers/interactions_test.go index f3a18a9..2210695 100644 --- a/pkg/service/handlers/interactions_test.go +++ b/pkg/service/handlers/interactions_test.go @@ -89,35 +89,6 @@ func TestInteractionHandlers(t *testing.T) { } }) - t.Run("HandleListInteractions_Mirror", func(t *testing.T) { - // Create a mirror interaction - sessionID := recorder.SessionID - mirrorRelPath := filepath.Join(sessionID, "mirror", "test", "0002-12-00-01.000-GET.http") - fullPath := filepath.Join(tmpDir, "interactions", mirrorRelPath) - os.MkdirAll(filepath.Dir(fullPath), 0755) - os.WriteFile(fullPath, []byte("### GET /test mirror\n\n> {% \n // Response: 200 OK\n%}\n"), 0644) - - req := httptest.NewRequest("GET", "/setup/interactions?category=mirror", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("Expected status 200, got %d", w.Code) - } - - var interactions []proxy.Interaction - if err := json.NewDecoder(w.Body).Decode(&interactions); err != nil { - t.Fatalf("Failed to decode interactions: %v", err) - } - - if len(interactions) != 1 { - t.Errorf("Expected 1 interaction for mirror, got %d", len(interactions)) - } - if interactions[0].Category != "mirror" { - t.Errorf("Expected category mirror, got %s", interactions[0].Category) - } - }) - t.Run("HandleGetInteractionContent", func(t *testing.T) { req := httptest.NewRequest("GET", "/setup/interaction-content?file="+relPath, nil) w := httptest.NewRecorder() diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go index 373c16f..5559a49 100644 --- a/pkg/service/handlers/main_test.go +++ b/pkg/service/handlers/main_test.go @@ -11,7 +11,6 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) r := chi.NewRouter() r.Use(server.OriginMiddleware) r.Use(server.ShortcutMiddleware) - r.Use(server.MirrorMiddleware) r.Use(server.RecordMiddleware) r.Get("/", server.HandleRoot) diff --git a/pkg/service/handlers/mirror_loop_prevention_test.go b/pkg/service/handlers/mirror_loop_prevention_test.go deleted file mode 100644 index 025febd..0000000 --- a/pkg/service/handlers/mirror_loop_prevention_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package handlers - -import ( - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/gesellix/bose-soundtouch/pkg/service/datastore" -) - -func TestMirrorMiddleware_InfiniteLoop(t *testing.T) { - tempDir, err := os.MkdirTemp("", "mirror-loop-test") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tempDir) - - ds := datastore.NewDataStore(tempDir) - _ = ds.Initialize() - - server := NewServer(ds, nil, "http://localhost:8000", false, false, false) - server.SetMirrorSettings(true, []string{"/loop"}, nil, "upstream") - - // Create a handler that would be the "next" in the chain. - // If the loop occurs, this will be called repeatedly. - callCount := 0 - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - callCount++ - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("ok")) - }) - - middleware := server.MirrorMiddleware(handler) - - // Simulate a mirror request by adding the X-Mirror-Request header. - // This is what performMirror adds to the proxied request. - req := httptest.NewRequest("GET", "/loop", nil) - req.Header.Set("X-Mirror-Request", "true") - w := httptest.NewRecorder() - - middleware.ServeHTTP(w, req) - - // If the fix is working, the middleware should see X-Mirror-Request and - // pass directly to the handler WITHOUT trying to mirror again. - if callCount != 1 { - t.Errorf("Expected 1 call to handler, got %d", callCount) - } - - if w.Code != http.StatusOK { - t.Errorf("Expected status 200, got %d", w.Code) - } -} diff --git a/pkg/service/handlers/mirror_middleware.go b/pkg/service/handlers/mirror_middleware.go deleted file mode 100644 index a317d77..0000000 --- a/pkg/service/handlers/mirror_middleware.go +++ /dev/null @@ -1,578 +0,0 @@ -package handlers - -import ( - "bytes" - "context" - "crypto/tls" - "encoding/json" - "encoding/xml" - "fmt" - "io" - "log" - "net/http" - "net/http/httputil" - "net/url" - "os" - "path" - "path/filepath" - "sort" - "strings" - "time" - - "github.com/gesellix/bose-soundtouch/pkg/models" - "github.com/gesellix/bose-soundtouch/pkg/service/marge" -) - -// MirrorMiddleware returns a middleware that mirrors specific requests to the Bose upstream. -func (s *Server) MirrorMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - enabled, endpoints, skipEndpoints, preferredSource := s.getMirrorSettings() - isMirrorRequest := r.Header.Get("X-Mirror-Request") == "true" - - if !enabled || isMirrorRequest || len(endpoints) == 0 || !s.shouldMirror(r.URL.Path, endpoints) || s.shouldSkipMirror(r.URL.Path, skipEndpoints) { - next.ServeHTTP(w, r) - return - } - - // Try to fetch snapshot from context - var snapshot *RequestSnapshot - if snap, ok := r.Context().Value(SnapshotKey).(*RequestSnapshot); ok { - snapshot = snap - } - - // Buffer request body if snapshot is missing (compatibility mode) - var bodyBytes []byte - if snapshot != nil { - bodyBytes = snapshot.Body - } else if r.Body != nil { - bodyBytes, _ = io.ReadAll(r.Body) - r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) - } - - // Use request context but detach it for background operations to prevent cancellation when the primary request finishes - detachedCtx := context.WithoutCancel(r.Context()) - if snapshot != nil { - detachedCtx = context.WithValue(detachedCtx, SnapshotKey, snapshot) - } - - if preferredSource == "upstream" { - s.mirrorUpstreamPreferred(detachedCtx, w, r, next, bodyBytes) - return - } - - s.mirrorLocalPreferred(detachedCtx, w, r, next, bodyBytes) - }) -} - -func (s *Server) getMirrorSettings() (bool, []string, []string, string) { - s.mu.RLock() - defer s.mu.RUnlock() - - return s.mirrorEnabled, s.mirrorEndpoints, s.skipMirrorEndpoints, s.preferredSource -} - -func (s *Server) shouldMirror(path string, endpoints []string) bool { - for _, pattern := range endpoints { - if matchPattern(pattern, path) { - return true - } - } - - return false -} - -func (s *Server) shouldSkipMirror(path string, skipEndpoints []string) bool { - for _, pattern := range skipEndpoints { - if matchPattern(pattern, path) { - return true - } - } - - return false -} - -func (s *Server) mirrorUpstreamPreferred(detachedCtx context.Context, w http.ResponseWriter, r *http.Request, next http.Handler, bodyBytes []byte) { - log.Printf("[MIRROR] Upstream is preferred source for %s %s", r.Method, r.URL.Path) - - // Clone request for local execution - rLocal := r.Clone(detachedCtx) - rLocal.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) - - localRecorder := &mirrorResponseRecorder{ - headers: make(http.Header), - body: &bytes.Buffer{}, - } - - // Run local handler in background - localDone := make(chan struct{}) - - go func() { - next.ServeHTTP(localRecorder, rLocal) - close(localDone) - }() - - // Clone request for mirror execution - rMirror := r.Clone(detachedCtx) - rMirror.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) - - // Execute mirror synchronously - mirrorRes := s.performMirror(rMirror) - - // Send mirror response to client - if mirrorRes != nil && mirrorRes.status != 0 && mirrorRes.status < 500 { - for k, vv := range mirrorRes.headers { - for _, v := range vv { - w.Header().Add(k, v) - } - } - - w.WriteHeader(mirrorRes.status) - _, _ = w.Write(mirrorRes.body.Bytes()) - } else { - // Fallback to local if mirror failed - log.Printf("[MIRROR_ERR] Mirror failed, falling back to local for %s", r.URL.Path) - <-localDone - - for k, vv := range localRecorder.headers { - for _, v := range vv { - w.Header().Add(k, v) - } - } - - if localRecorder.status == 0 { - localRecorder.status = http.StatusOK - } - - w.WriteHeader(localRecorder.status) - _, _ = w.Write(localRecorder.body.Bytes()) - } - - // Perform parity check once local is done - go func() { - <-localDone - - if mirrorRes != nil { - s.checkParity(r, localRecorder, mirrorRes) - } - }() -} - -func (s *Server) mirrorLocalPreferred(detachedCtx context.Context, w http.ResponseWriter, r *http.Request, next http.Handler, bodyBytes []byte) { - // Default: local is preferred source of truth - // Prepare local request - r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) - - // Wrap response writer to capture local response for parity check - localRecorder := &mirrorResponseRecorder{ - headers: make(http.Header), - body: &bytes.Buffer{}, - } - - wrappedWriter := &parityResponseWriter{ - ResponseWriter: w, - recorder: localRecorder, - } - - log.Printf("[MIRROR] Mirroring %s %s %s", r.Method, r.URL.Path, map[bool]string{true: "asynchronously", false: "synchronously"}[r.Method == http.MethodGet]) - - rMirror := r.Clone(detachedCtx) - rMirror.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) - - next.ServeHTTP(wrappedWriter, r) - - go func() { - mirrorRes := s.performMirror(rMirror) - s.checkParity(r, localRecorder, mirrorRes) - }() -} - -type parityResponseWriter struct { - http.ResponseWriter - recorder *mirrorResponseRecorder -} - -func (p *parityResponseWriter) Header() http.Header { - return p.recorder.Header() -} - -func (p *parityResponseWriter) Write(b []byte) (int, error) { - if p.recorder.status == 0 { - p.WriteHeader(http.StatusOK) - } - - p.recorder.body.Write(b) - - return p.ResponseWriter.Write(b) -} - -func (p *parityResponseWriter) WriteHeader(statusCode int) { - p.recorder.status = statusCode - // Copy headers to the real response writer before writing the header - for k, vv := range p.recorder.headers { - for _, v := range vv { - p.ResponseWriter.Header().Add(k, v) - } - } - - p.ResponseWriter.WriteHeader(statusCode) -} - -func (s *Server) performMirror(r *http.Request) *mirrorResponseRecorder { - // Try to fetch snapshot from context - var snapshot *RequestSnapshot - if snap, ok := r.Context().Value(SnapshotKey).(*RequestSnapshot); ok { - snapshot = snap - } - - // Preserve request body for recording before it gets consumed by the proxy - var requestForRecording *http.Request - if s.recorder != nil && s.recordEnabled { - requestForRecording = r.Clone(r.Context()) - if snapshot != nil { - // Use snapshot for both proxy and recording - r.Body = io.NopCloser(bytes.NewReader(snapshot.Body)) - requestForRecording.Body = io.NopCloser(bytes.NewReader(snapshot.Body)) - } else if r.Body != nil { - // Compatibility fallback - bodyBytes, err := io.ReadAll(r.Body) - if err != nil { - log.Printf("[MIRROR_ERR] Failed to read request body for recording: %v", err) - } else { - // Restore body for proxy - r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) - // Set body for recording - requestForRecording.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) - } - } - - // Ensure Content-Length is set for the recording clone - if requestForRecording.Body != nil { - if snapshot != nil { - requestForRecording.ContentLength = int64(len(snapshot.Body)) - } - } - } - - host := r.Host - if host == "" || host == "localhost" { - host = "streaming.bose.com" - } - - scheme := "https" - if strings.HasPrefix(host, "127.0.0.1") || strings.HasPrefix(host, "localhost") { - scheme = "http" - } - - targetURL := scheme + "://" + host - - target, err := url.Parse(targetURL) - if err != nil { - log.Printf("[MIRROR_ERR] Failed to parse target URL %s: %v", targetURL, err) - return nil - } - - // AllowInsecureUpstreamTLS is opt-in via settings.json — defaults to - // false so verification stays on. The opt-in exists for deployments - // stuck behind a broken Bose-cloud certificate chain post EOS. - settings, _ := s.ds.GetSettings() - insecure := settings.AllowInsecureUpstreamTLS - - // Create a proxy that doesn't write to the original ResponseWriter - proxy := &httputil.ReverseProxy{ - Rewrite: func(pr *httputil.ProxyRequest) { - pr.SetURL(target) - pr.Out.Host = target.Host - pr.Out.Header.Set("X-Mirror-Request", "true") - }, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure}, - }, - } - - // Capture response for parity check and recording - recorder := &mirrorResponseRecorder{ - headers: make(http.Header), - body: &bytes.Buffer{}, - } - - proxy.ModifyResponse = func(res *http.Response) error { - res.Header.Set("X-Proxy-Origin", "upstream-mirror") - - // Record mirrored interaction with preserved request body - if s.recorder != nil && s.recordEnabled && requestForRecording != nil { - _ = s.recorder.Record("mirror", requestForRecording, res) - } - - return nil - } - - // We use a dummy ResponseWriter to capture the results - proxy.ServeHTTP(recorder, r) - - log.Printf("[MIRROR] Mirror completed for %s with status %d", r.URL.Path, recorder.status) - - return recorder -} - -// checkParity compares local response with upstream response. -func (s *Server) checkParity(req *http.Request, local, upstream *mirrorResponseRecorder) { - if local.status == 0 { - local.status = 200 - } - - if upstream.status == 0 { - upstream.status = 200 - } - - mismatch := false - reasons := []string{} - - if local.status != upstream.status { - mismatch = true - - reasons = append(reasons, fmt.Sprintf("Status mismatch: local %d, upstream %d", local.status, upstream.status)) - } - - // Compare Content-Type - localCT := local.headers.Get("Content-Type") - - upstreamCT := upstream.headers.Get("Content-Type") - if localCT != upstreamCT { - mismatch = true - - reasons = append(reasons, fmt.Sprintf("Content-Type mismatch: local %s, upstream %s", localCT, upstreamCT)) - } - - // Compare bodies - localBody := local.body.Bytes() - upstreamBody := upstream.body.Bytes() - - if !bytes.Equal(localBody, upstreamBody) { - // If both are XML, try a whitespace-insensitive comparison - isXML := (strings.Contains(localCT, "/xml") || strings.Contains(localCT, "+xml")) && - (strings.Contains(upstreamCT, "/xml") || strings.Contains(upstreamCT, "+xml")) - - if isXML { - if !s.compareXMLWhitespaceInsensitive(localBody, upstreamBody) { - mismatch = true - - reasons = append(reasons, "Body content mismatch (XML)") - } - } else { - mismatch = true - - reasons = append(reasons, "Body content mismatch") - } - } - - if mismatch { - log.Printf("[PARITY] Mismatch detected for %s %s: %v", req.Method, req.URL.Path, reasons) - s.saveParityMismatch(req, local, upstream, reasons) - } - - // Trigger synchronization if this is a /full response from upstream - if strings.Contains(req.URL.Path, "/full") && upstream.status == http.StatusOK { - var resp models.AccountFullResponse - if err := xml.Unmarshal(upstream.body.Bytes(), &resp); err == nil { - log.Printf("[MIRROR] Triggering sync from upstream /full response for %s", req.URL.Path) - marge.LogSyncDiff(s.ds, &resp) - - if err = marge.SyncFromAccountFull(s.ds, &resp); err != nil { - log.Printf("[MIRROR_ERR] Failed to sync from upstream /full: %v", err) - } - } else { - log.Printf("[MIRROR_ERR] Failed to unmarshal upstream /full response: %v", err) - } - } -} - -// compareXMLWhitespaceInsensitive compares two XML bodies ignoring whitespace between elements. -func (s *Server) compareXMLWhitespaceInsensitive(local, upstream []byte) bool { - clean := func(b []byte) string { - s := string(b) - // Remove XML declaration for easier comparison - if strings.HasPrefix(s, ""); idx != -1 { - s = s[idx+2:] - } - } - - // Normalize whitespace: - // 1. Remove all whitespace between elements (i.e., between > and <) - // 2. Trim surrounding whitespace - var result strings.Builder - - inTag := false - - for i := 0; i < len(s); i++ { - c := s[i] - switch { - case c == '<': - inTag = true - - result.WriteByte(c) - case c == '>': - inTag = false - - result.WriteByte(c) - case inTag: - result.WriteByte(c) - default: - // We are between tags, only add if not whitespace - if c != ' ' && c != '\n' && c != '\r' && c != '\t' { - result.WriteByte(c) - } - } - } - - return strings.TrimSpace(result.String()) - } - - return clean(local) == clean(upstream) -} - -func (s *Server) saveParityMismatch(req *http.Request, local, upstream *mirrorResponseRecorder, reasons []string) { - record := map[string]interface{}{ - "timestamp": time.Now().Format(time.RFC3339), - "method": req.Method, - "path": req.URL.Path, - "reasons": reasons, - "local": map[string]interface{}{ - "status": local.status, - "headers": local.headers, - "body": local.body.String(), - }, - "upstream": map[string]interface{}{ - "status": upstream.status, - "headers": upstream.headers, - "body": upstream.body.String(), - }, - } - - data, err := json.MarshalIndent(record, "", " ") - if err != nil { - log.Printf("[PARITY_ERR] Failed to marshal parity record: %v", err) - return - } - - dir := filepath.Join(s.ds.DataDir, "parity_mismatches") - _ = s.ds.MkdirAllUnderBase(dir, 0755) - - // Build a single filename component from req.URL.Path. After replacing - // the obvious separators, gate on filepath.IsLocal so a malicious path - // containing ".." or platform-specific separators we missed cannot - // escape `dir`. The write itself goes through DataStore's *os.Root so - // the runtime enforces containment regardless of what's in pathSegment. - pathSegment := strings.ReplaceAll(req.URL.Path, "/", "_") - pathSegment = strings.ReplaceAll(pathSegment, "\\", "_") - - if !filepath.IsLocal(pathSegment) { - pathSegment = "invalid" - } - - filename := fmt.Sprintf("%d_%s.json", time.Now().Unix(), pathSegment) - _ = s.ds.WriteFileUnderBase(filepath.Join(dir, filename), data, 0644) -} - -type mirrorResponseRecorder struct { - status int - headers http.Header - body *bytes.Buffer -} - -func (m *mirrorResponseRecorder) Header() http.Header { - return m.headers -} - -func (m *mirrorResponseRecorder) Write(b []byte) (int, error) { - return m.body.Write(b) -} - -func (m *mirrorResponseRecorder) WriteHeader(statusCode int) { - m.status = statusCode -} - -// matchPattern checks if a path matches a pattern with wildcards (*) -func matchPattern(pattern, name string) bool { - matched, _ := path.Match(pattern, name) - if matched { - return true - } - // Also try prefix match if pattern ends with /* - if strings.HasSuffix(pattern, "/*") { - prefix := strings.TrimSuffix(pattern, "/*") - if strings.HasPrefix(name, prefix) { - return true - } - } - - return false -} - -// HandleListParityMismatches returns a list of parity mismatches. -func (s *Server) HandleListParityMismatches(w http.ResponseWriter, _ *http.Request) { - dir := filepath.Join(s.ds.DataDir, "parity_mismatches") - if _, err := os.Stat(dir); os.IsNotExist(err) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte("[]")) - - return - } - - files, err := os.ReadDir(dir) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - var mismatches []interface{} - - for _, file := range files { - if !file.IsDir() && strings.HasSuffix(file.Name(), ".json") { - data, err := os.ReadFile(filepath.Join(dir, file.Name())) - if err == nil { - var record interface{} - if json.Unmarshal(data, &record) == nil { - // Add filename as ID for downloading/deletion if needed - if m, ok := record.(map[string]interface{}); ok { - m["id"] = file.Name() - mismatches = append(mismatches, m) - } else { - mismatches = append(mismatches, record) - } - } - } - } - } - - // Sort by timestamp descending if possible - sort.Slice(mismatches, func(i, j int) bool { - mi, oki := mismatches[i].(map[string]interface{}) - - mj, okj := mismatches[j].(map[string]interface{}) - if oki && okj { - ti, _ := mi["timestamp"].(string) - tj, _ := mj["timestamp"].(string) - - return ti > tj - } - - return false - }) - - w.Header().Set("Content-Type", "application/json") - - if err := json.NewEncoder(w).Encode(mismatches); err != nil { - log.Printf("[PARITY_ERR] Failed to encode mismatches: %v", err) - } -} - -// HandleClearParityMismatches deletes all parity mismatch records. -func (s *Server) HandleClearParityMismatches(w http.ResponseWriter, _ *http.Request) { - dir := filepath.Join(s.ds.DataDir, "parity_mismatches") - _ = os.RemoveAll(dir) - _ = os.MkdirAll(dir, 0755) - - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte("{\"ok\": true}")) -} diff --git a/pkg/service/handlers/mirror_preferred_test.go b/pkg/service/handlers/mirror_preferred_test.go deleted file mode 100644 index 4875f0c..0000000 --- a/pkg/service/handlers/mirror_preferred_test.go +++ /dev/null @@ -1,161 +0,0 @@ -package handlers - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" - - "github.com/gesellix/bose-soundtouch/pkg/service/datastore" -) - -func TestMirrorMiddleware_PreferredSource(t *testing.T) { - tempDir, err := os.MkdirTemp("", "mirror-test") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tempDir) - - ds := datastore.NewDataStore(tempDir) - _ = ds.Initialize() - - // 1. Setup local handler - r := http.NewServeMux() - r.HandleFunc("/test/local", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Source", "local") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("local response")) - }) - - // 2. Setup "upstream" mock server - upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Source", "upstream") - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte("upstream response")) - })) - defer upstreamServer.Close() - - // 3. Setup our server with MirrorMiddleware - server := NewServer(ds, nil, "http://localhost:8000", false, false, false) - server.SetMirrorSettings(true, []string{"/test/local"}, nil, "local") - - // We need to trick performMirror to use our mock upstream. - // performMirror uses r.Host. - upstreamURL := upstreamServer.URL - upstreamHost := strings.TrimPrefix(upstreamURL, "http://") - - middleware := server.MirrorMiddleware(r) - - t.Run("PreferredLocal", func(t *testing.T) { - server.SetMirrorSettings(true, []string{"/test/local"}, nil, "local") - - req := httptest.NewRequest("GET", "/test/local", nil) - req.Host = upstreamHost // So performMirror targets the mock upstream - w := httptest.NewRecorder() - - middleware.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("Expected status 200, got %d", w.Code) - } - if w.Header().Get("X-Source") != "local" { - t.Errorf("Expected X-Source: local, got %s", w.Header().Get("X-Source")) - } - if w.Body.String() != "local response" { - t.Errorf("Expected 'local response', got '%s'", w.Body.String()) - } - }) - - t.Run("PreferredUpstream", func(t *testing.T) { - server.SetMirrorSettings(true, []string{"/test/local"}, nil, "upstream") - - req := httptest.NewRequest("GET", "/test/local", nil) - req.Host = upstreamHost - w := httptest.NewRecorder() - - middleware.ServeHTTP(w, req) - - if w.Code != http.StatusCreated { - t.Errorf("Expected status 201, got %d", w.Code) - } - if w.Header().Get("X-Source") != "upstream" { - t.Errorf("Expected X-Source: upstream, got %s", w.Header().Get("X-Source")) - } - if w.Body.String() != "upstream response" { - t.Errorf("Expected 'upstream response', got '%s'", w.Body.String()) - } - }) - - t.Run("FallbackToLocal", func(t *testing.T) { - server.SetMirrorSettings(true, []string{"/test/local"}, nil, "upstream") - - // Use a non-existent host for mirror to trigger failure - req := httptest.NewRequest("GET", "/test/local", nil) - req.Host = "nonexistent.invalid" - w := httptest.NewRecorder() - - middleware.ServeHTTP(w, req) - - // Should fallback to local - if w.Code != http.StatusOK { - t.Errorf("Expected status 200 (fallback), got %d", w.Code) - } - if w.Header().Get("X-Source") != "local" { - t.Errorf("Expected X-Source: local (fallback), got %s", w.Header().Get("X-Source")) - } - }) -} - -func TestSettingsAPI_PreferredSource(t *testing.T) { - tempDir, err := os.MkdirTemp("", "settings-test") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tempDir) - - ds := datastore.NewDataStore(tempDir) - _ = ds.Initialize() - - server := NewServer(ds, nil, "http://localhost:8000", false, false, false) - - // Test GET initial - req := httptest.NewRequest("GET", "/setup/settings", nil) - w := httptest.NewRecorder() - server.HandleGetSettings(w, req) - - var settings map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &settings) - if settings["preferred_source"] != "" && settings["preferred_source"] != "local" { - t.Errorf("Initial preferred_source unexpected: %v", settings["preferred_source"]) - } - - // Test UPDATE - update := map[string]interface{}{ - "server_url": "http://localhost:8000", - "preferred_source": "upstream", - } - body, err := json.Marshal(update) - if err != nil { - t.Fatalf("Failed to marshal update: %v", err) - } - req = httptest.NewRequest("POST", "/setup/settings", bytes.NewBuffer(body)) - w = httptest.NewRecorder() - server.HandleUpdateSettings(w, req) - - if w.Code != http.StatusOK { - t.Errorf("POST /setup/settings failed: %d", w.Code) - } - - if server.preferredSource != "upstream" { - t.Errorf("Server preferredSource did not update: %s", server.preferredSource) - } - - // Verify persistence - persisted, _ := ds.GetSettings() - if persisted.PreferredSource != "upstream" { - t.Errorf("Datastore did not persist PreferredSource: %s", persisted.PreferredSource) - } -} diff --git a/pkg/service/handlers/mirror_test.go b/pkg/service/handlers/mirror_test.go deleted file mode 100644 index 6a687b4..0000000 --- a/pkg/service/handlers/mirror_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package handlers - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/gesellix/bose-soundtouch/pkg/service/datastore" - "github.com/gesellix/bose-soundtouch/pkg/service/proxy" -) - -func TestMirroring(t *testing.T) { - tempDir, err := os.MkdirTemp("", "st-mirror-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tempDir) - - ds := datastore.NewDataStore(tempDir) - - // Create a mock Bose Upstream - boseUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Only handle requests to the actual path - if strings.HasSuffix(r.URL.Path, "/recent") { - w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("")) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer boseUpstream.Close() - - // Setup local server - r, server := setupRouter("http://localhost:8001", ds) - - // Setup recorder - recorder := proxy.NewRecorder(tempDir) - server.SetRecorder(recorder) - server.SetRecordEnabled(true) - server.SetMirrorSettings(true, []string{"/streaming/account/*/device/*/recent"}, nil, "local") - - ts := httptest.NewServer(r) - defer ts.Close() - - account := "123" - deviceID := "DEV1" - - // Ensure the datastore has the necessary directories for the local handler - deviceDir := filepath.Join(tempDir, "accounts", account, "devices", deviceID) - _ = os.MkdirAll(deviceDir, 0755) - _ = os.WriteFile(filepath.Join(deviceDir, "Recents.xml"), []byte(""), 0644) - _ = os.WriteFile(filepath.Join(deviceDir, "Sources.xml"), []byte(""), 0644) - - t.Run("Mirrored Endpoint", func(t *testing.T) { - path := "/streaming/account/" + account + "/device/" + deviceID + "/recent" - req, _ := http.NewRequest("GET", ts.URL+path, nil) - // We set the host to our mock upstream so performMirror finds it - req.Host = strings.TrimPrefix(boseUpstream.URL, "http://") - - res, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - t.Errorf("Expected status OK, got %v", res.Status) - } - - // Wait a bit for the async mirror to complete and be recorded - time.Sleep(500 * time.Millisecond) - - // Check if the interaction was recorded twice - // Category: self - matchesSelf, _ := filepath.Glob(filepath.Join(tempDir, "interactions", "*", "self", "*", "*")) - if len(matchesSelf) == 0 { - // List directory for debugging - files, _ := filepath.Glob(filepath.Join(tempDir, "interactions", "*", "*", "*", "*")) - t.Errorf("Expected to find local interaction in logs (category: self). Found: %v", files) - } - - // Category: mirror - matchesMirror, _ := filepath.Glob(filepath.Join(tempDir, "interactions", "*", "mirror", "*", "*")) - if len(matchesMirror) == 0 { - // List directory for debugging - files, _ := filepath.Glob(filepath.Join(tempDir, "interactions", "*", "*", "*", "*")) - t.Errorf("Expected to find mirrored interaction in logs (category: mirror). Found: %v", files) - } - }) - - t.Run("Parity Mismatch Header Capture", func(t *testing.T) { - // The previous test already triggered a mismatch because the bodies and content-types differ - // local: (from file), content-type: text/xml (default) - // upstream: , content-type: application/vnd.bose.streaming-v1.2+xml - - matchesMismatch, _ := filepath.Glob(filepath.Join(tempDir, "parity_mismatches", "*.json")) - if len(matchesMismatch) == 0 { - t.Fatal("Expected to find parity mismatch JSON file") - } - - data, err := os.ReadFile(matchesMismatch[0]) - if err != nil { - t.Fatalf("Failed to read mismatch file: %v", err) - } - - var record struct { - Local struct { - Headers http.Header `json:"headers"` - } `json:"local"` - Upstream struct { - Headers http.Header `json:"headers"` - } `json:"upstream"` - } - - if err := json.Unmarshal(data, &record); err != nil { - t.Fatalf("Failed to unmarshal mismatch record: %v", err) - } - - if len(record.Local.Headers) == 0 { - t.Error("Expected local headers in parity mismatch, got none") - } - if len(record.Upstream.Headers) == 0 { - t.Error("Expected upstream headers in parity mismatch, got none") - } - - // Check specifically for Content-Type - if ct := record.Local.Headers.Get("Content-Type"); ct == "" { - t.Error("Expected Content-Type in local headers") - } - if ct := record.Upstream.Headers.Get("Content-Type"); ct != "application/vnd.bose.streaming-v1.2+xml" { - t.Errorf("Expected Upstream Content-Type application/vnd.bose.streaming-v1.2+xml, got %s", ct) - } - }) - - t.Run("POST Request Body Preservation", func(t *testing.T) { - // Set recorder to synchronous mode for testing - os.Setenv("RECORDER_ASYNC", "false") - defer os.Unsetenv("RECORDER_ASYNC") - - // Create a mock upstream that echoes back the request body - postUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == "POST" && strings.HasSuffix(r.URL.Path, "/scmudc/A81B6A536A98") { - // Read the request body - body, err := io.ReadAll(r.Body) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - - // Echo back the body in response for verification - w.Header().Set("Content-Type", "application/json") - w.Header().Set("X-Request-Body-Length", fmt.Sprintf("%d", len(body))) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(body) - return - } - w.WriteHeader(http.StatusNotFound) - })) - defer postUpstream.Close() - - // Setup mirroring for the POST endpoint - server.SetMirrorSettings(true, []string{"/v1/scmudc/*"}, nil, "local") - - requestBody := `{"envelope":{"monoTime":234906,"payloadProtocolVersion":"3.1","payloadType":"scmudc","protocolVersion":"1.0","time":"2026-02-25T23:03:14.976349+00:00","uniqueId":"A81B6A536A98"},"payload":{"deviceInfo":{"boseID":"3230304","deviceID":"A81B6A536A98","deviceType":"SoundTouch 10","serialNumber":"I6332527703739342000020","softwareVersion":"27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29","systemSerialNumber":"069231P63364828AE"},"events":[{"data":{"play-state":"PAUSE_STATE"},"monoTime":234904,"time":"2026-02-25T23:03:14.973466+00:00","type":"play-state-changed"}]}}` - - path := "/v1/scmudc/A81B6A536A98" - req, _ := http.NewRequest("POST", ts.URL+path, strings.NewReader(requestBody)) - req.Header.Set("Content-Type", "text/json; charset=utf-8") - req.Host = strings.TrimPrefix(postUpstream.URL, "http://") - - res, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - t.Errorf("Expected status OK, got %v", res.Status) - } - - // Wait briefly for the synchronous recording to complete - time.Sleep(100 * time.Millisecond) - - // Check if the mirrored interaction was recorded with the request body - matchesMirror, _ := filepath.Glob(filepath.Join(tempDir, "interactions", "*", "mirror", "v1", "scmudc", "*", "*-POST.http")) - if len(matchesMirror) == 0 { - // Try broader search pattern - allHttpFiles, _ := filepath.Glob(filepath.Join(tempDir, "interactions", "*", "*", "*", "*", "*", "*.http")) - t.Errorf("Expected to find mirrored POST interaction. All .http files found: %v", allHttpFiles) - } else { - // Read the recorded mirrored interaction - recordedContent, err := os.ReadFile(matchesMirror[0]) - if err != nil { - t.Fatalf("Failed to read recorded mirror interaction: %v", err) - } - - recordedStr := string(recordedContent) - - // Check if the request body was preserved in the recording - if !strings.Contains(recordedStr, requestBody) { - t.Errorf("Request body not found in mirrored recording. Content: %s", recordedStr) - } - - // Check if the Content-Type header was preserved - if !strings.Contains(recordedStr, "Content-Type: text/json; charset=utf-8") { - t.Errorf("Content-Type header not found in mirrored recording. Content: %s", recordedStr) - } - } - }) -} - -// SetRecordEnabled is a helper for testing -func (s *Server) SetRecordEnabled(enabled bool) { - s.mu.Lock() - defer s.mu.Unlock() - s.recordEnabled = enabled -} diff --git a/pkg/service/handlers/parity_mismatch_repro_v3_test.go b/pkg/service/handlers/parity_mismatch_repro_v3_test.go deleted file mode 100644 index 68e0fa4..0000000 --- a/pkg/service/handlers/parity_mismatch_repro_v3_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package handlers - -import ( - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" - "time" - - "github.com/gesellix/bose-soundtouch/pkg/service/constants" - "github.com/gesellix/bose-soundtouch/pkg/service/datastore" -) - -func TestParityMismatchReproduction_V3(t *testing.T) { - tempDir, _ := os.MkdirTemp("", "marge-test") - defer os.RemoveAll(tempDir) - ds := datastore.NewDataStore(tempDir) - - r, _ := setupRouter("http://localhost:8001", ds) - ts := httptest.NewServer(r) - defer ts.Close() - - // Upstream input for POST /recent (extracted from user description) - // We'll use the same source metadata as provided in the upstream response - // to see if we can "learn" it and echo it back correctly. - requestBody := ` - - stationurl - /v1/playback/station/s104811 - 1LIVE Chillout - - 2017-07-20T16:43:48.000+00:00 - dummy-token-base64 - 25 - - - 2017-07-20T16:43:48.000+00:00 - - 14774275 -` - - account := "1234567" - device := "001122334455" - url := fmt.Sprintf("%s/streaming/account/%s/device/%s/recent", ts.URL, account, device) - - t.Run("POST /recent and check parity", func(t *testing.T) { - res, err := http.Post(url, "application/xml", strings.NewReader(requestBody)) - if err != nil { - t.Fatal(err) - } - defer res.Body.Close() - - if res.StatusCode != http.StatusCreated { - t.Errorf("Expected status 201, got %v", res.Status) - } - - body, _ := io.ReadAll(res.Body) - bodyStr := string(body) - t.Logf("POST /recent Response:\n%s\n", bodyStr) - - if !strings.Contains(bodyStr, constants.XMLHeader) { - t.Error("Missing XML declaration with standalone=\"yes\"") - } - - // 2. Large ID (YYMMDDxxx format) - prefix := time.Now().UTC().Format("060102") - if !strings.Contains(bodyStr, fmt.Sprintf(`id="%s`, prefix)) { - t.Errorf("Recent ID missing expected prefix %s. Body: %s", prefix, bodyStr) - } - - // 3. Date Formatting (.000+00:00) - if !strings.Contains(bodyStr, `.000+00:00`) { - t.Error("Dates are missing milliseconds or incorrect offset") - } - - // 4. Source Learning - // Check for provider ID 25 - if !strings.Contains(bodyStr, `25`) { - t.Errorf("Source provider ID mismatch: expected 25 for TuneIn in element. Body: %s", bodyStr) - } - // Check for credential - if !strings.Contains(bodyStr, `dummy-token-base64`) { - t.Errorf("Secret value was not preserved in element. Body: %s", bodyStr) - } - - // 6. Indentation check (2 spaces) - if !strings.Contains(bodyStr, "\n /v1/playback/station/s104811") { - t.Errorf("Incorrect indentation for location: expected 2 spaces. Body: %s", bodyStr) - } - }) - - t.Run("Verify GET /recents consistency", func(t *testing.T) { - recentsUrl := fmt.Sprintf("%s/streaming/account/%s/device/%s/recent", ts.URL, account, device) - res, err := http.Get(recentsUrl) - if err != nil { - t.Fatal(err) - } - defer res.Body.Close() - - body, _ := io.ReadAll(res.Body) - bodyStr := string(body) - - t.Logf("GET /recents Local Response:\n%s\n", bodyStr) - - if !strings.Contains(bodyStr, `25`) { - t.Error("Source provider ID missing in GET /recents") - } - }) -} - -func TestXMLWhitespaceInsensitivity(t *testing.T) { - s := &Server{} - local := []byte(constants.XMLHeader + ` - - Test -`) - upstream := []byte(constants.XMLHeader + ` - - Test -`) - - if !s.compareXMLWhitespaceInsensitive(local, upstream) { - t.Error("compareXMLWhitespaceInsensitive failed for simple whitespace difference") - } - - upstreamNoSpaces := []byte(constants.XMLHeader + `Test`) - if !s.compareXMLWhitespaceInsensitive(local, upstreamNoSpaces) { - t.Error("compareXMLWhitespaceInsensitive failed for no-whitespace upstream") - } -} diff --git a/pkg/service/handlers/recorder_middleware.go b/pkg/service/handlers/recorder_middleware.go index f90747d..99977b5 100644 --- a/pkg/service/handlers/recorder_middleware.go +++ b/pkg/service/handlers/recorder_middleware.go @@ -7,6 +7,7 @@ import ( "io" "net" "net/http" + "path" ) // RecordMiddleware returns a middleware that records "self" requests and responses. @@ -22,7 +23,7 @@ func (s *Server) RecordMiddleware(next http.Handler) http.Handler { s.mu.RUnlock() for _, pattern := range internalPaths { - if matchPattern(pattern, r.URL.Path) { + if matched, _ := path.Match(pattern, r.URL.Path); matched { next.ServeHTTP(w, r) return } diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index bfa63f8..a42190f 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -43,10 +43,6 @@ type Server struct { dnsEnabled bool dnsUpstream []string dnsBindAddr string - mirrorEnabled bool - mirrorEndpoints []string - skipMirrorEndpoints []string - preferredSource string internalPaths []string shortcuts map[string]int recorder *proxy.Recorder @@ -512,17 +508,6 @@ func (s *Server) SetMgmtConfig(username, password string) { s.mgmtPassword = password } -// SetMirrorSettings sets the mirroring settings for the server. -func (s *Server) SetMirrorSettings(enabled bool, endpoints, skipEndpoints []string, preferredSource string) { - s.mu.Lock() - defer s.mu.Unlock() - - s.mirrorEnabled = enabled - s.mirrorEndpoints = endpoints - s.skipMirrorEndpoints = skipEndpoints - s.preferredSource = preferredSource -} - // SetInternalPaths sets the internal paths for the server. func (s *Server) SetInternalPaths(paths []string) { s.mu.Lock() diff --git a/pkg/service/handlers/snapshot_integrity_test.go b/pkg/service/handlers/snapshot_integrity_test.go deleted file mode 100644 index 996f9d3..0000000 --- a/pkg/service/handlers/snapshot_integrity_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package handlers - -import ( - "bytes" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/gesellix/bose-soundtouch/pkg/service/datastore" - "github.com/gesellix/bose-soundtouch/pkg/service/proxy" -) - -func TestSnapshotIntegrity_SelfAndMirror(t *testing.T) { - tempDir, err := os.MkdirTemp("", "recording-test-*") - if err != nil { - t.Fatal(err) - } - defer os.RemoveAll(tempDir) - - ds := datastore.NewDataStore(tempDir) - recorder := proxy.NewRecorder(tempDir) - s := NewServer(ds, nil, "http://localhost:8000", false, false, true) - s.SetRecorder(recorder) - s.SetMirrorSettings(true, []string{"/mirror/*"}, nil, "local") - - // Upstream mock - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - w.Header().Set("X-Request-Body-Length", fmt.Sprintf("%d", len(body))) - w.WriteHeader(http.StatusOK) - w.Write([]byte("upstream response")) - })) - defer upstream.Close() - - // Configure mirror to point to our mock upstream - s.SetMirrorSettings(true, []string{"/mirror/*"}, nil, "local") - // We need to override the host in performMirror but for tests we can just mock it via env if needed or rely on the fact that performMirror uses r.Host - - handler := s.SnapshotMiddleware(s.MirrorMiddleware(s.RecordMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - w.WriteHeader(http.StatusOK) - w.Write([]byte("local response: " + string(body))) - })))) - - bodyText := `{"test":"integrity"}` - req := httptest.NewRequest("POST", "http://localhost:8000/mirror/test", strings.NewReader(bodyText)) - req.Header.Set("Content-Type", "application/json") - // Override r.Host to point to our mock upstream (performMirror will use it) - req.Host = strings.TrimPrefix(upstream.URL, "http://") - - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - - // Wait for async operations - time.Sleep(200 * time.Millisecond) - - var selfFile, mirrorFile string - _ = filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if !info.IsDir() && strings.HasSuffix(path, ".http") { - if strings.Contains(path, "/self/") { - selfFile = path - } else if strings.Contains(path, "/mirror/") { - mirrorFile = path - } - } - return nil - }) - - // Retry a few times for async operations - for i := 0; i < 10 && (selfFile == "" || mirrorFile == ""); i++ { - time.Sleep(100 * time.Millisecond) - _ = filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if !info.IsDir() && strings.HasSuffix(path, ".http") { - if strings.Contains(path, "/self/") { - selfFile = path - } else if strings.Contains(path, "/mirror/") { - mirrorFile = path - } - } - return nil - }) - } - - if selfFile == "" { - // Try one more scan - filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error { - if !info.IsDir() && strings.HasSuffix(path, ".http") { - if strings.Contains(path, "/self/") { - selfFile = path - } else if strings.Contains(path, "/mirror/") { - mirrorFile = path - } - } - return nil - }) - } - - if selfFile == "" { - t.Fatal("Self recording file not found") - } - if mirrorFile == "" { - t.Fatal("Mirror recording file not found") - } - - selfContent, _ := os.ReadFile(selfFile) - mirrorContent, _ := os.ReadFile(mirrorFile) - - if !bytes.Contains(selfContent, []byte(bodyText)) { - t.Errorf("Self recording missing body. Content:\n%s", string(selfContent)) - } - if !bytes.Contains(mirrorContent, []byte(bodyText)) { - t.Errorf("Mirror recording missing body. Content:\n%s", string(mirrorContent)) - } -} diff --git a/pkg/service/handlers/web/css/style.css b/pkg/service/handlers/web/css/style.css index eff53e7..b0c9b26 100644 --- a/pkg/service/handlers/web/css/style.css +++ b/pkg/service/handlers/web/css/style.css @@ -118,7 +118,6 @@ pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px; } .category-self { background-color: #e3f2fd; color: #0d47a1; } .category-upstream { background-color: #f3e5f5; color: #7b1fa2; } -.category-mirror { background-color: #fff3e0; color: #e65100; } .status-success { background-color: #e8f5e9; color: #2e7d32; } .status-error { background-color: #ffebee; color: #c62828; } diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html index 4f92e9e..5a75bda 100644 --- a/pkg/service/handlers/web/index.html +++ b/pkg/service/handlers/web/index.html @@ -30,14 +30,11 @@ - - - + @@ -227,52 +224,6 @@ -
- Endpoint Mirroring: -
- -
- -
- - - -
- Note: Mirroring sends matching - requests (including full headers) to the - official Bose servers for parity comparison. If - Redact Sensitive Data is enabled in - Proxy Settings, credentials will be masked in - logs and recordings, but full - headers are always sent to Bose to ensure - service compatibility. -
-
-
-
Spotify Integration:
@@ -1248,7 +1199,6 @@ -
@@ -1487,160 +1437,7 @@
- -
-

Parity Analysis

-

- Detection of discrepancies between AfterTouch local - responses and official Bose Cloud responses for mirrored - endpoints. -

- -
-
-

Parity Mismatches

-
- - -
-
- -
- - - - - - - - - - - - - - - -
TimeMethodPathReasonsAction
- Loading mismatches... -
-
-
- - -
- - +

Local Account Details

diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js index 5e5a55e..1028b66 100644 --- a/pkg/service/handlers/web/js/script.js +++ b/pkg/service/handlers/web/js/script.js @@ -275,18 +275,6 @@ async function fetchSettings() { dnsCurrentUpstream.innerText = ""; } - if (settings.mirror_enabled !== undefined) { - document.getElementById("mirror-enabled").checked = settings.mirror_enabled; - } - if (settings.preferred_source !== undefined) { - document.getElementById("preferred-source-upstream").checked = settings.preferred_source === "upstream"; - } - if (settings.mirror_endpoints) { - document.getElementById("mirror-endpoints").value = settings.mirror_endpoints.join("\n"); - } - if (settings.skip_mirror_endpoints) { - document.getElementById("skip-mirror-endpoints").value = settings.skip_mirror_endpoints.join("\n"); - } if (settings.internal_paths) { document.getElementById("internal-paths").value = settings.internal_paths.join("\n"); } @@ -373,18 +361,6 @@ async function updateSettings() { dns_enabled: document.getElementById("dns-enabled").checked, dns_upstream: document.getElementById("dns-upstream").value, dns_bind_addr: document.getElementById("dns-bind").value, - mirror_enabled: document.getElementById("mirror-enabled").checked, - preferred_source: document.getElementById("preferred-source-upstream").checked ? "upstream" : "local", - mirror_endpoints: document - .getElementById("mirror-endpoints") - .value.split("\n") - .map((s) => s.trim()) - .filter((s) => s !== ""), - skip_mirror_endpoints: document - .getElementById("skip-mirror-endpoints") - .value.split("\n") - .map((s) => s.trim()) - .filter((s) => s !== ""), internal_paths: document .getElementById("internal-paths") .value.split("\n") @@ -527,10 +503,6 @@ function openTab(evt, tabId) { fetchDNSDiscoveries(); } - if (tabId === "tab-parity") { - fetchParityMismatches(); - } - if (tabId === "tab-account") { fetchAccountList(); } @@ -1525,122 +1497,6 @@ async function fetchDeviceEvents(deviceId) { list.innerHTML = `Error loading events: ${error.message}`; } } - -async function fetchParityMismatches() { - const list = document.getElementById("parity-mismatches-list"); - list.innerHTML = 'Loading mismatches...'; - - try { - const response = await fetch("/setup/parity-mismatches"); - const mismatches = await response.json(); - - list.innerHTML = ""; - if (!mismatches || mismatches.length === 0) { - list.innerHTML = 'No parity mismatches detected yet.'; - return; - } - - mismatches.forEach((m) => { - const tr = document.createElement("tr"); - tr.style.borderBottom = "1px solid #eee"; - - const time = m.timestamp || ""; - const method = m.method || ""; - const path = m.path || ""; - const reasons = (m.reasons || []).join(", "); - - tr.innerHTML = ` - ${time} - ${method} - ${path} - ${reasons} - - `; - list.appendChild(tr); - }); - } catch (error) { - list.innerHTML = `Error loading mismatches: ${error.message}`; - } -} - -async function clearParityMismatches() { - if (!confirm("Are you sure you want to clear all parity mismatch records?")) return; - try { - await fetch("/setup/parity-mismatches", {method: "DELETE"}); - fetchParityMismatches(); - document.getElementById("parity-diff-view").style.display = "none"; - } catch (error) { - alert("Failed to clear mismatches: " + error.message); - } -} - -function viewParityMismatch(m, forceRichDiff = false) { - document.getElementById("diff-path-display").innerText = m.method + " " + m.path; - const reasonsList = document.getElementById("diff-reasons-list"); - reasonsList.innerHTML = ""; - (m.reasons || []).forEach((r) => { - const li = document.createElement("li"); - li.innerText = r; - reasonsList.appendChild(li); - }); - - document.getElementById("diff-local-meta").innerText = `Status: ${m.local.status}`; - document.getElementById("diff-upstream-meta").innerText = `Status: ${m.upstream.status}`; - - const localCT = (m.local.headers && m.local.headers["Content-Type"]) ? m.local.headers["Content-Type"][0] : ""; - const upstreamCT = (m.upstream.headers && m.upstream.headers["Content-Type"]) ? m.upstream.headers["Content-Type"][0] : ""; - - const localBody = formatBody(m.local.body, localCT); - const upstreamBody = formatBody(m.upstream.body, upstreamCT); - - const diffSizeThreshold = 50000; // 50KB - const isLarge = localBody.length > diffSizeThreshold || upstreamBody.length > diffSizeThreshold; - const warningEl = document.getElementById("diff-size-warning"); - - if (isLarge && !forceRichDiff) { - warningEl.style.display = "block"; - const forceBtn = document.getElementById("force-rich-diff-btn"); - forceBtn.onclick = () => viewParityMismatch(m, true); - - document.getElementById("diff-local-body").innerText = localBody; - document.getElementById("diff-upstream-body").innerText = upstreamBody; - } else { - warningEl.style.display = "none"; - if (typeof Diff !== 'undefined') { - const diff = Diff.diffChars(localBody, upstreamBody); - const localEl = document.getElementById("diff-local-body"); - const upstreamEl = document.getElementById("diff-upstream-body"); - - localEl.innerHTML = ""; - upstreamEl.innerHTML = ""; - - diff.forEach((part) => { - const span = document.createElement('span'); - if (part.added) { - span.className = 'diff-added'; - span.innerText = part.value; - upstreamEl.appendChild(span); - } else if (part.removed) { - span.className = 'diff-removed'; - span.innerText = part.value; - localEl.appendChild(span); - } else { - localEl.appendChild(document.createTextNode(part.value)); - upstreamEl.appendChild(document.createTextNode(part.value)); - } - }); - } else { - document.getElementById("diff-local-body").innerText = localBody; - document.getElementById("diff-upstream-body").innerText = upstreamBody; - } - } - - document.getElementById("parity-diff-view").style.display = "block"; - document - .getElementById("parity-diff-view") - .scrollIntoView({behavior: "smooth"}); -} - function formatBody(body, contentType) { if (!body) return ""; contentType = (contentType || "").toLowerCase(); @@ -1698,7 +1554,6 @@ document.addEventListener("DOMContentLoaded", () => { fetchDevices(); triggerDiscovery(); fetchVersion(); - fetchParityMismatches(); const syncBtn = document.getElementById("sync-now-btn"); if (syncBtn) syncBtn.onclick = startSync; diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go index 7d799b2..c5b89ef 100644 --- a/pkg/service/setup/setup.go +++ b/pkg/service/setup/setup.go @@ -104,11 +104,6 @@ type MigrationSummary struct { // observe the SSH-ping cost in the wild. ResolveIPDurationMS int64 `json:"resolve_ip_duration_ms,omitempty"` - MirrorEnabled bool `json:"mirror_enabled"` - MirrorEndpoints []string `json:"mirror_endpoints,omitempty"` - SkipMirrorEndpoints []string `json:"skip_mirror_endpoints,omitempty"` - PreferredSource string `json:"preferred_source,omitempty"` - // Telnet (port 17000) preflight state — populated when the user is about to // or has just used MigrationMethodTelnet. TelnetReachable bool `json:"telnet_reachable"` @@ -348,17 +343,6 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti // 3. Provide HTTPS URL for testing (consumed by the migration UI) summary.ServerHTTPSURL = m.buildServerHTTPSURL(targetURL) - // 4. Mirroring settings - if m.DataStore != nil { - settings, err := m.DataStore.GetSettings() - if err == nil { - summary.MirrorEnabled = settings.MirrorEnabled - summary.MirrorEndpoints = settings.MirrorEndpoints - summary.SkipMirrorEndpoints = settings.SkipMirrorEndpoints - summary.PreferredSource = settings.PreferredSource - } - } - // 5. Merge telnet preflight results (started in parallel at the top). telnetResult := <-telnetCh summary.TelnetReachable = telnetResult.TelnetReachable diff --git a/pkg/service/setup/setup_test.go b/pkg/service/setup/setup_test.go index 4d749bd..c1a0142 100644 --- a/pkg/service/setup/setup_test.go +++ b/pkg/service/setup/setup_test.go @@ -380,45 +380,6 @@ func TestGetMigrationSummary_WithProxyOptions(t *testing.T) { } } -func TestGetMigrationSummary_MirrorSettings(t *testing.T) { - tempDir, err := os.MkdirTemp("", "st-test-mirror-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tempDir) - - ds := datastore.NewDataStore(tempDir) - settings := datastore.Settings{ - MirrorEnabled: true, - MirrorEndpoints: []string{"/recent", "/presets"}, - } - if err := ds.SaveSettings(settings); err != nil { - t.Fatalf("Failed to save settings: %v", err) - } - - m := NewManager("http://localhost:8000", ds, nil) - - // Mock server for live info - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/xml") - _, _ = fmt.Fprint(w, `Test`) - })) - defer server.Close() - - summary, err := m.GetMigrationSummary(server.Listener.Addr().String(), "", "", nil) - if err != nil { - t.Fatalf("GetMigrationSummary failed: %v", err) - } - - if !summary.MirrorEnabled { - t.Error("Expected MirrorEnabled to be true in summary") - } - - if len(summary.MirrorEndpoints) != 2 || summary.MirrorEndpoints[0] != "/recent" { - t.Errorf("Expected MirrorEndpoints [/recent /presets], got %v", summary.MirrorEndpoints) - } -} - func TestCheckCACertTrusted(t *testing.T) { tempDir, err := os.MkdirTemp("", "ca-trust-test") if err != nil {