The payoff for the previous five commits. Two new download
sources slot into the existing DownloadSourcePlugin contract,
backed by Prowlarr (search) + the torrent or usenet client
adapter (transfer) + archive_pipeline (post-extract walk). They
appear in the Download Source dropdown next to Soulseek / Tidal /
Lidarr / etc. and also participate in hybrid mode.
Pipeline (both plugins, mirror shape):
1. search(query) → ProwlarrClient.search filtered to the right
protocol, projected into TrackResult / AlbumResult shapes the
existing search UI already speaks. Filename field encodes the
indexer's download URL (or magnet URI for torrents) so
download() can recover it later.
2. download() → decodes URL, hands it to the active adapter
(qBittorrent / Transmission / Deluge for torrent; SABnzbd /
NZBGet for usenet), spawns a background poll thread that
tracks progress + reports the adapter-reported save_path.
3. On 'seeding' / 'completed' → archive_pipeline walks the save
directory, extracts any archives the downloader didn't
already unpack, picks the first audio file as the canonical
file_path. Matches the Lidarr client's single-track-pick
contract — picking which specific track to import happens in
post-processing.
- core/download_plugins/torrent.py: TorrentDownloadPlugin +
module-level helpers (_decode_filename, _guess_quality_from_title,
_parse_indexer_id_filter, _adapter_state_to_display, _row_to_status).
Uses get_active_torrent_adapter() so a settings change to the
client type takes effect without restart.
- core/download_plugins/usenet.py: UsenetDownloadPlugin —
parallel shape, reuses the torrent module's helpers. Different
enough states (no seeding, no magnet) to warrant its own class
but cheap to keep in lockstep.
- core/download_plugins/registry.py: register 'torrent' and
'usenet' plugins. Per the registry docstring this is the only
wiring point needed — the orchestrator picks them up
automatically via the iteration helpers.
- webui/index.html: 'Torrent Only (via Prowlarr)' + 'Usenet Only
(via Prowlarr)' added to the Download Source dropdown. New
redirect card (#prowlarr-source-redirect) explains that the
actual config lives on the Indexers & Downloaders tab —
shown whenever torrent or usenet is in the active source set.
- webui/static/settings.js: HYBRID_SOURCES gets two new entries
so hybrid mode can pick them up. updateDownloadSourceUI now
toggles the redirect card based on active sources.
- tests/test_torrent_usenet_plugins.py: 23 tests covering pure
helpers (filename encode/decode round-trip incl. magnet URIs,
quality guesser, state mapping), search projection logic
(protocol filter, drops without URLs, magnet-preferred-over-URL,
filename encoding, neutralised soulseek-specific score fields),
is_configured (both prowlarr + adapter required), finalize
(picks first audio file, errors on empty dir / missing save_path),
clear/get_all lifecycle, DownloadSourcePlugin protocol
conformance, and registry membership.
Restructure the Indexers & Downloaders tab to mirror the
Paths & Organization / Post-Processing / Library Preferences
pattern on the Library page — each subsystem (Indexers / Torrent
Client / Usenet Client) gets its own collapsible section header
with a status dot, hint, and animated arrow.
Visual cues borrowed from Lidarr but rendered in SoulSync's
existing dark-glass theme:
- Intro hero card at the top of the tab with a 1-2-3 flow:
Indexers find releases → Downloader fetches → SoulSync imports.
Accent-color stepper pills + sub-copy summarising what's
optional vs required.
- Status dot in each section header — grey 'unknown' before
testing, green after Test Connection succeeds, red on failure.
Driven by _setIndStatusDot() helper called from each test
handler. Soft glow on the active states.
- Per-service service-title color accents matching existing
spotify-title / tidal-title pattern: prowlarr-title (orange,
Prowlarr brand), torrent-title (sky blue, qBit family),
usenet-title (violet).
- Indexer list cards replace the inline-emoji list — proper
protocol badges (Torrent vs Usenet pill), monospace id chip,
privacy tag, dimmed appearance when the indexer is disabled
in Prowlarr.
- Indexers section starts open; Torrent + Usenet start collapsed
since most users only configure one protocol.
No behavior changes — same fields, same endpoints, same save
flow. Pure visual restructure of the panels added in the previous
three commits.
Third commit in the torrent + usenet rollout. SoulSync now also
speaks the two big usenet downloaders through a sibling adapter
contract that mirrors the torrent adapter set. All three layers are
now stood up — Prowlarr finds releases, the torrent adapter and the
usenet adapter each know how to ship work to the underlying client.
A later commit wires Prowlarr search results through the adapters
and through the archive-extract-match pipeline.
- core/usenet_clients/base.py: UsenetClientAdapter Protocol +
UsenetStatus dataclass. Uniform state set covers usenet-specific
phases (queued / downloading / extracting / verifying / repairing /
completed / failed / paused).
- core/usenet_clients/__init__.py: adapter_for_type factory +
get_active_adapter that reads usenet_client.type each call.
- core/usenet_clients/sabnzbd.py: REST adapter. ?apikey=... auth,
mode=addurl and mode=addfile (multipart) for add_nzb. Reads both
the active queue and the recent history so completed / failed
jobs surface in get_all. Parses SAB's HH:MM:SS ``timeleft`` into
seconds.
- core/usenet_clients/nzbget.py: JSON-RPC adapter. HTTP Basic auth,
``append`` method for add_nzb (auto-detects URL vs base64 NZB),
``editqueue`` with GroupPause/GroupResume/GroupDelete/GroupFinalDelete
for state changes. Reads NZBGet's 64-bit split size fields
(FileSizeHi + FileSizeLo) preferentially over the legacy
FileSizeMB aggregate.
- core/connection_test.py: 'usenet_client' branch picks the right
adapter, runs check_connection, surfaces per-client error
messages (different credentials needed).
- config/settings.py: usenet_client.{type, url, api_key, username,
password, category} defaults + both api_key and password marked
encrypted-at-rest.
- web_server.py: 'usenet_client' added to the /api/settings POST
allow-list.
- webui/index.html: new Usenet Client panel on the Indexers &
Downloaders tab. Type picker swaps the credential fields between
API-key (SABnzbd) and username+password (NZBGet).
- webui/static/settings.js: load/save wiring, updateUsenetClientUI
for the credential field swap, testUsenetClientConnection.
- webui/static/helper.js: WHATS_NEW + VERSION_MODAL_SECTIONS entry.
Second commit in the torrent + usenet rollout. SoulSync now speaks
three different BitTorrent client APIs through one uniform adapter
contract — picks the active client by config and dispatches the same
verbs to whichever backend the user uses. Each adapter handles its
own auth quirk (qBit cookie + CSRF Referer, Transmission session-id
renegotiation, Deluge JSON-RPC session) and maps native state
strings onto a shared 7-value set so the rest of the app stays
client-agnostic.
- core/torrent_clients/base.py: TorrentClientAdapter Protocol +
TorrentStatus dataclass. Eight verbs: is_configured, check_connection,
add_torrent (URL/magnet), add_torrent_file (raw bytes), get_status,
get_all, remove, pause, resume.
- core/torrent_clients/__init__.py: adapter_for_type factory +
get_active_adapter that reads torrent_client.type each call so
settings changes take effect without restart.
- core/torrent_clients/qbittorrent.py: WebUI v2 adapter. Cookie auth
via /api/v2/auth/login, transparent 403 re-login, Referer header
to satisfy qBit's CSRF guard. add_torrent returns the just-added
hash via /torrents/info sort=added_on (qBit's add endpoint doesn't
echo the hash).
- core/torrent_clients/transmission.py: RPC adapter. Auto-resolves
bare host URLs to /transmission/rpc, handles the 409 + new
X-Transmission-Session-Id renegotiation transparently, accepts
HTTP basic auth. add_torrent_file base64-encodes payload per spec.
- core/torrent_clients/deluge.py: Deluge 2.x JSON-RPC adapter.
Password-only auth, distinguishes magnet vs HTTP URL at the RPC
method layer, applies category via Label plugin (best-effort —
label plugin is optional).
- core/connection_test.py: 'torrent_client' branch picks the right
adapter, runs check_connection, surfaces a per-client error
message.
- config/settings.py: torrent_client.{type, url, username, password,
category, save_path} defaults + torrent_client.password in the
encrypted-at-rest secrets list.
- web_server.py: 'torrent_client' added to the /api/settings POST
allow-list so saved config persists.
- webui/index.html: new Torrent Client panel on the Indexers &
Downloaders tab — client-type dropdown, URL, username, password,
category, optional save path, Test Connection.
- webui/static/settings.js: load/save wiring + testTorrentClientConnection.
- webui/static/helper.js: WHATS_NEW + VERSION_MODAL_SECTIONS entry.
First commit toward torrent and usenet download sources. Prowlarr is
the indexer manager component of the *arr stack — it exposes Usenet
and torrent indexers behind a single Newznab-style API so SoulSync
doesn't have to integrate each indexer individually. This commit
wires up Prowlarr as a search-only source; the torrent and usenet
download client adapters land in the next commits and plug into
this search surface.
- core/prowlarr_client.py: sync-backed async client. is_configured,
check_connection, get_indexers, search by Newznab category. Music
category constants (3000 all / 3010 MP3 / 3040 lossless / etc.).
- core/connection_test.py: 'prowlarr' branch hits /api/v1/system/status
for the Test Connection button.
- web_server.py: GET /api/prowlarr/indexers returns the live indexer
list (id, name, protocol, enabled, privacy). Settings POST allow-list
now accepts 'prowlarr' so saved config persists.
- config/settings.py: prowlarr.{url, api_key, indexer_ids} defaults
plus prowlarr.api_key in the encrypted-at-rest secrets list.
- webui/index.html: new "Indexers & Downloaders" tab on Settings with
the Prowlarr panel (URL, API key, Test, Refresh Indexer List,
optional indexer-ID allowlist).
- webui/static/settings.js: load/save wiring, testProwlarrConnection,
loadProwlarrIndexers (HTML-escapes user-supplied indexer names).
- webui/static/helper.js: WHATS_NEW 2.6.0 unreleased block plus a
curated VERSION_MODAL_SECTIONS entry.
Register MusicBrainz as a first-class metadata source alongside Deezer, iTunes, Spotify, Discogs, and Hydrabase. Expose the shared client through metadata services, add the settings option, and expand the MusicBrainz search adapter with source-compatible artist, album, track, and detail methods.
Carry MusicBrainz IDs through similar-artist discovery, recommended artists, artist map serialization, and personalized playlist selection. Update DB migrations and lookup filters so similar_artist_musicbrainz_id is preserved on older schemas and used for source requirements and library exclusion.
Normalize MusicBrainz album adapter output for import context and add regression coverage for registry mapping, typed album conversion, and similar-artist filtering. Verified by user with 120 focused tests passing.
Follows the exact same standard as Tidal, Qobuz, HiFi, and Deezer.
registry.py — import + register AmazonDownloadClient as 'amazon'.
amazon_download_client.py — read amazon_download.quality / allow_fallback
from config on init; pass quality as preferred_codec to AmazonClient;
_download_sync codec waterfall respects allow_fallback flag.
download_orchestrator.py — reload_settings() updates preferred_codec +
allow_fallback on the live client after a settings save. 'amazon' added
to _streaming_sources so search_and_download_best routes it correctly.
api_call_tracker.py — 'amazon' registered in RATE_LIMITS (120/min),
SERVICE_LABELS, and SERVICE_ORDER so API call monitoring shows Amazon.
web_server.py — 'amazon_download' added to the settings service loop.
'amazon' added to serverless_sources (no slskd probe needed). Streaming
file-finder extended to handle amazon username + ||asin||title encoding
(extension-less fuzzy match, same as Tidal/Qobuz/HiFi). New endpoint:
GET /api/amazon/test-connection → checks T2Tunes proxy status.
webui/index.html — amazon-download-settings-container: quality dropdown
(flac/opus/eac3), allow-fallback checkbox, test-connection button.
webui/static/settings.js — 'Amazon Music' added to HYBRID_SOURCES,
_hybridSourceEnabled, allSources mode list, loadSettings(), saveSettings()
payload, updateDownloadSourceUI() show/hide + auto-test. New
testAmazonConnection() function.
Previously hardcoded at 3s (5s for tracks >10min) — files drifting
past that got quarantined with no user override. Live recordings,
alternate masterings, and some legitimate uploads routinely drift
further.
New setting `post_processing.duration_tolerance_seconds`. Default 0
means "use auto-scaled defaults" (unchanged behavior for users who
don't touch it). Positive value overrides the per-track defaults.
Capped at 60s — past that the check is effectively off.
Logic lifted to pure helper `resolve_duration_tolerance` in
file_integrity.py. Coerces every plausible input (None / empty /
zero / negative / unparseable / above-cap / numeric string / float)
to either a float override or None for auto. 12 tests pin every
shape.
Wired into `core/imports/pipeline.py` at the integrity-check call
site — runs for ALL matched downloads (Soulseek / Tidal / Qobuz /
HiFi / YouTube / Deezer-direct) since they all share that pipeline.
Settings UI input under Settings → Metadata → Post-Processing.
- new soulseek.search_min_delay_seconds knob forces a gap between
consecutive searches; smooths the burst pattern that trips ISP
anti-abuse (Reddit report: Bell Canada cuts the WAN after rapid
peer-connection spikes) even when the existing 35/220 sliding-window
cap isn't hit
- throttle math lifted to a pure compute_search_wait_seconds helper so
the gate logic is testable independent of asyncio.sleep + the
singleton client
- new field on settings → connections → soulseek; default 0 = disabled
so existing users see no change
15 helper-boundary tests pin defaults / no-throttle, sliding-window
cap (legacy), min-delay (the new burst-smoother), max-of-both gates,
and defensive paths.
Adds the user's Tidal favorited tracks ("My Collection" in the Tidal
app) as a virtual playlist alongside their real playlists, mirroring
how Spotify's "Liked Songs" is treated.
Reporter (yug1900) located the working endpoint after the prior
`/v2/favorites?filter[type]=TRACKS` attempt returned empty data —
that endpoint is scoped to collections the third-party app created
itself, not personal favorites. Real endpoint:
GET /v2/userCollectionTracks/me/relationships/items
?countryCode=US&locale=en-US&include=items
Cursor-paginated (20 per page, follow `links.next` with
`page[cursor]=...` until exhausted). Response only carries
track-level attributes — artist + album NAMES come back as
relationship-link stubs, not embedded data.
Implementation:
* Two-phase fetch — `_iter_collection_track_ids` walks the cursor
chain to enumerate every track id (cheap, IDs only), then
`get_collection_tracks` batch-hydrates 20 IDs at a time through
the existing `_get_tracks_batch` helper which already knows how
to `include=artists,albums`. No duplication of the JSON:API
artist/album parse, no new dataclass shape.
* Virtual playlist `tidal-favorites` appended to the end of
`/api/tidal/playlists`. ID intentionally has no colon —
sync-services.js renderer interpolates IDs into CSS selectors
via template literals (`#tidal-card-${p.id} .foo`) and a `:`
would parse as a CSS pseudo-class operator.
* `tidal_client.get_playlist("tidal-favorites")` recognizes the
virtual id and dispatches to the collection path internally, so
every per-id consumer gets it for free: detail endpoint, mirror
auto-refresh automation, "build Spotify discovery from Tidal
playlist" flow.
OAuth scope expansion:
* Added `collection.read` to both OAuth flows (the
`core/tidal_client.py::authenticate` standalone path AND the
`web_server.py::auth_tidal` web flow — they were independent
scope strings that both needed updating).
* Added `prompt=consent` to both flows — without it Tidal silently
returns a token carrying only the ORIGINAL scope set even after
re-authentication, because Tidal treats the existing
authorization as still valid.
* New `disconnect()` method + `POST /api/tidal/disconnect`
endpoint + Disconnect button next to Authenticate in Settings →
Connections → Tidal — required for users whose existing token
predates the scope expansion (forces a clean grant).
Reconnect-needed UI hint:
* `_collection_needs_reconnect` flag set on 401/403 from the
collection endpoint, cleared on next successful walk, NOT set
on 5xx (transient server errors must not falsely tell the user
to reconnect).
* Listing endpoint reads the flag and surfaces a placeholder card
titled "Favorite Tracks (reconnect Tidal to enable)" with a
description pointing at Settings, so the user has something
visible to act on instead of a silently missing row.
Diagnostic logging — collection request URL + response status +
first 300 bytes of body now logged at info level so future "why
is my collection empty" reports can be diagnosed from app.log
without needing live reproduction.
22 new tests pin: cursor walk (full chain, max-ids cap mid-page +
at page boundary), auth gates (no token / 401 / 403 all bail
clean), reconnect-flag lifecycle (set on 401/403, cleared on next
successful walk, NOT set on 5xx), forward-compat type filter
(non-track entries skipped), count helper, batch hydration
delegation + chunking at the 20-per-batch cap, partial-batch
failure containment, virtual-id dispatch (real playlist ids still
flow through the normal path).
Closes#502.
Plug the previously-built SoundcloudClient (PR #478, the build-and-verify
phase) into every place a download source needs to appear. Follows the
same wiring contract as Tidal/Qobuz/HiFi/Deezer/Lidarr — orchestrator
routing, hybrid-mode picker, search dispatch, queue/cancel/clear,
provenance + library history, sidebar source label, settings UI all
work plug-and-play.
Backend wiring:
- `core/download_orchestrator.py` — import SoundcloudClient, _safe_init
it at startup, add to _client() lookup, get_source_status(),
check_connection's sources_to_check default, search source_names map,
search_and_download_best _streaming_sources tuple, download
source_map + source_names, and every iteration loop in
reload_settings download-path-update / get_all_downloads /
get_download_status / cancel_download (route + iterate) /
clear_all_completed_downloads / cancel_all_downloads.
- `core/downloads/monitor.py` — added SoundCloud to the per-client
loop that fetches active downloads outside the orchestrator (uses
getattr fallback for older soulseek_client snapshots).
- `core/downloads/task_worker.py` — added SoundCloud (and Lidarr,
which was missing too — bonus fix) to source_clients dict for hybrid
fallback dispatch.
- `core/downloads/validation.py` — added 'soundcloud' to
_streaming_sources so SoundCloud results go through the matching
engine validation path instead of the Soulseek quality-filter path.
- `core/imports/side_effects.py` — three call sites: source_map for
download_source label written to library_history, streaming-source
guard for the `||`-encoded stream_id parsing, and source_service
map for provenance recording. All three now include 'soundcloud'.
- `web_server.py` — five streaming-source detection tuples updated.
New `/api/soundcloud/status` endpoint returns
{available, configured, reachable} mirroring the Deezer/HiFi
status-endpoint pattern; reachability runs a real cheap yt-dlp
search so the settings Test Connection button gives a meaningful
pass/fail signal.
- `config/settings.py` — added empty `soundcloud_download` defaults
block so future tier-2 OAuth (SoundCloud Go+ session) doesn't have
to migrate existing configs.
Frontend:
- `webui/index.html` — new `<option value="soundcloud">` in the
download-source-mode dropdown, SoundCloud added to both hidden
legacy hybrid-source selects, new settings container with info
text + Test Connection button.
- `webui/static/settings.js` — HYBRID_SOURCES entry (with the
SoundCloud cloud SVG icon), _hybridSourceEnabled default,
updateDownloadSourceUI container display, allSources for legacy
hybrid picker, testSoundcloudConnection function (hits the new
status endpoint, color-codes the result), saveSettings
soundcloud_download empty block.
- `webui/static/shared-helpers.js` — sidebar source-name map
includes SoundCloud + Lidarr (Lidarr was also missing, bonus fix).
- `webui/static/helper.js` — WHATS_NEW entry under '2.4.2' dev cycle
describing the user-visible change in the chill terse voice.
Tests:
- `tests/test_download_orchestrator_soundcloud.py` — 14 integration
tests verifying the wiring: client constructed at startup, _client
lookup resolves 'soundcloud', get_source_status includes it,
download dispatcher routes username='soundcloud' to the SoundCloud
client (and unknown usernames still fall back to Soulseek), hybrid
search iterates SoundCloud when in order and skips it cleanly when
unconfigured, get_all_downloads / get_download_status / cancel /
clear walk SoundCloud, soundcloud-only mode dispatches only to
SoundCloud, _streaming_sources tuple in validation includes
'soundcloud'.
- `tests/downloads/test_download_orchestrator.py` — added
`soundcloud` to the test fixture's _build_orchestrator helper so
the new orchestrator attribute doesn't AttributeError in pre-
existing tests that bypass __init__.
Verified:
- Full suite green (1728 passed, 2 deselected for soundcloud_live)
- Ruff clean
- Live SoundCloud-only mode search returns 25 SoundCloud tracks for
"kendrick lamar luther" in <2s, returning properly-shaped
TrackResult objects with username='soundcloud' and dispatch-key
filename ready for the download path.
Out of scope (intentional deferrals):
- SoundCloud Go+ OAuth tier (256 kbps AAC) — anonymous-only for now.
Adding auth later is a settings-page extension, no orchestrator
changes needed.
- Album/playlist support — SoundCloud has playlists but they don't
map to the album model the rest of SoulSync expects. Singles only.
- Keep the primary metadata provider snapshot generic and move Spotify auth/rate-limit details into a separate status object.
- Update the websocket fixture and dashboard/settings consumers to read the two buckets independently.
- Show Discogs with a lock icon until a personal access token is present.
- Prevent selecting locked Discogs and steer users to the Discogs settings section.
- Keep metadata-source availability and selection state synced as the token changes.
- Show Spotify with a lock icon when it is not currently selectable.
- Keep the explanation in the hover title instead of cluttering the dropdown label.
- Redirect users to the Spotify settings section when they try to pick a locked source.
- Drive the Spotify settings accordion from live auth state instead of treating it as configured/healthy when the session is missing.
- Reuse the existing yellow missing-state styling so unauthenticated Spotify is visually distinct from active Spotify.
- Keep the shared status refresh path updating the settings view immediately after auth changes.
- Make the Spotify auth completion popup notify the opener across callback origins.
- Refresh service status in the settings UI after auth completes so the button flips to Disconnect immediately.
- Keep the standalone callback instruction page and the main app flow working with the same completion signal.
- Flatten the Spotify service-status rendering so it shows rate-limit and recovery states explicitly, while otherwise displaying the active metadata provider directly.
- Keep the Spotify auth controls and metadata-source picker aligned with the real session state after authenticate and disconnect flows.
- Return "Unmapped" for unknown metadata source labels instead of implying iTunes.
- Update the metadata registry tests to cover the new label fallback.
- Send Spotify auth completion back to the opener so the settings page refreshes immediately
- Make the local auth flow go straight through to Spotify instead of showing the temporary instruction page
- Keep the remote/docker instruction page available for manual callback setups
- Sync Spotify status, connect/disconnect buttons, and metadata source selection after auth and disconnect
- Keep the disconnect behavior aligned with the active primary metadata source
- Hide the auth button when a Spotify session is active
- Treat disconnect as a session change, not a provider swap
- Share metadata source labels in the registry
- Tighten rate-limit copy around Spotify-specific behavior
Six items from a Cin-style line-by-line pass on PR #383:
- resolve_cors_origins: list of non-string entries (`[None, 123]`) now
drops them instead of coercing to junk strings like `'None'`/`'123'`.
- will_reject: backwards-compat shim removed. Production callers always
pass `request.scheme` (Flask-guaranteed); the shim only existed for
tests/non-Flask callers and made the production code path branchier
than necessary. Tests now pass scheme explicitly.
- maybe_log: redundant `if not origin` early-return dropped. will_reject
handles missing origin (engineio's own behavior — server.py:207).
- RejectionLogger.__init__: `int(dedup_cap)` wrapped in try/except so
bad-type input falls back to DEFAULT_DEDUP_CAP instead of raising.
- web_server.py: docstring on the before_request hook explains why the
hook fires on every request (Flask doesn't scope before_request to a
path prefix; the early-return string compare is the cheapest option).
- settings.js: cors-origins URL regex tightened from `[^\s/]+` to
`[^\s/?#]+` so query/fragment chars don't pass validation. Engineio
would silently fail to match those anyway; better to flag at save.
Test changes:
- parametrize gained an explicit `scheme` column (12 cases updated).
- New explicit case: scheme-mismatch rejects (engineio compares full
`{scheme}://{host}` strings).
- `test_will_reject_falls_back_to_host_only_when_no_scheme_info`
deleted — the shim it tested is gone.
- `test_will_reject_honors_x_forwarded_host` now passes scheme info.
Net: -9 production lines, -3 test lines. Production code path is
straight-line. 603 tests pass.
Self-review pass on the security fix uncovered five issues, all fixed
here:
1. will_reject scheme handling. Engineio compares full {scheme}://{host}
strings, not just hostnames. A TLS-terminating proxy can leave the
backend seeing http while the browser's Origin is https — engineio
rejects, but the original predictor said "allow" → no helpful log
line. Added request_scheme + forwarded_proto params, build full
candidate strings to match engineio.
2. EITHER-forwarded-header rule. Engineio adds the forwarded candidate
when EITHER X-Forwarded-Proto OR X-Forwarded-Host is present (it
falls back to HTTP_HOST for the missing one). The original predictor
only added it when forwarded_host was set — false negative for
misconfigs sending only X-Forwarded-Proto. Now mirrors engineio.
3. will_reject incorrectly rejected missing-Origin requests. Engineio
(server.py:207: `if origin: validate`) skips CORS validation when
no Origin header is sent — non-browser clients (curl etc.) are
intentionally permitted. The original code rejected them. Test was
asserting the wrong behavior. Both fixed.
4. RejectionLogger had unbounded dedup set growth. A hostile actor
opening connections from many distinct fake origins would fill
memory unboundedly. Capped at 100 unique origins (configurable);
when cap hit, one overflow notice is emitted and further rejections
are silently dropped until restart.
5. Lock pattern: the overflow log path called logger.warning() while
holding the dedup lock, inconsistent with the normal path. Fixed
to pick the message under the lock and log after release. Critical
section is now minimal and uniform.
Plus polish:
- Stale module docstring fixed (said "empty list" instead of "None").
- settings.js validates each cors_origins line against a URL regex on
save; toasts a one-shot warning if entries are malformed (resolver
silently filters them, but user gets feedback now).
- web_server.py wiring passes request.scheme + X-Forwarded-Proto so
the predictor has full proxy info.
Tests:
- 51 unit tests in tests/test_socketio_cors.py (was 45). New cases:
* scheme comparison (5 cases including TLS-terminating proxies)
* forwarded_proto-alone misconfig
* missing-origin matches engineio (was asserting wrong behavior)
* dedup cap with overflow + reset
* default cap is reasonable (uses public DEFAULT_DEDUP_CAP constant)
Engineio behavior independently verified by reading engineio/server.py
and engineio/base_server.py source. Predictor mirrors both files.
604 tests pass.
Closes#366 (reported by JohnBaumb).
Socket.IO was initialized with `cors_allowed_origins='*'`, accepting
WebSocket connections from any origin. A malicious site could open a
WS to a user's local SoulSync instance and exfiltrate live progress /
toast / activity events.
This commit:
- Defaults to engineio's same-origin behavior (`cors_allowed_origins=None`),
which automatically honors X-Forwarded-Host so reverse proxies that
send that header (Caddy / Traefik by default, properly-configured
Nginx) work transparently.
- Adds a `security.cors_origins` config setting + Settings → Security
textarea where users behind unusual proxies / Electron wrappers /
cross-origin integrations can whitelist their origin. Accepts comma
or newline separated values; `*` on its own line opts back into the
legacy wildcard with a startup-warning log.
- Logs a clear warning the first time engineio rejects each unique
origin, naming the rejected Origin and request Host and pointing
users to the settings field. Without this, engineio silently 403s
the upgrade and the user just sees a half-broken UI with no clue
why. Threadsafe dedup so a hostile origin can't spam logs.
Logic lives in `core/socketio_cors.py` (resolver, rejection
predictor, dedup logger class, startup-status emitter) — pure
functions, no Flask dependency. `web_server.py` adds 23 lines of
wiring and imports.
Important catch during review: my first pass used `cors_allowed_origins=[]`
as the "secure default." Reading engineio's source revealed `[]` actually
means "DISABLE CORS HANDLING" (engineio/server.py:202: `if cors_allowed_origins != []:`)
— identical security to `'*'`. Fixed to use `None` (engineio's actual
same-origin sentinel) and pinned with a regression test that asserts
the resolver never returns `[]` for any input shape.
Tests:
- tests/test_socketio_cors.py — 45 unit tests covering 19 resolver shape
cases (None, empty, whitespace, comma, newline, garbage types, lists),
the `[]`-must-never-be-returned security regression, 12 rejection
prediction cases, X-Forwarded-Host handling, dedup logger behavior,
threadsafe race (8 threads × 50 hammers → exactly 1 warning), and
startup-status emitter outputs.
Frontend:
- Settings → Security gains an "Allowed WebSocket Origins" textarea
with help text explaining same-origin default + when to add a domain
+ the `*` opt-out.
- helper.js — new '2.4.1' WHATS_NEW block (hidden until version bump)
with a chill-voice entry describing the change.
Conftest.py left at `'*'` — test environment, no security concern.
598 tests pass.