fix(orbit): match a join link against every form of a server address (#1356)

* fix(orbit): match a join link against every form of a server address

An invite carries the address `serverShareBaseUrl` produced: normalized, and
for a profile with two addresses the public one, since the guest is not on the
host's LAN. The join check compared that against the raw `profile.url`, so it
missed two cases and refused with "You don't have access to …" even though the
user has a working account on that exact server:

- An address saved without a scheme (`host:4533`) never matched, because the
  link always carries the normalized `http://host:4533`. Everything else in the
  app normalizes on use, so streaming and sync work and only joining fails.
- An address kept in `alternateUrl` was never looked at, which is precisely the
  LAN-plus-public setup whose links carry the public address.

Both call sites now share `profileServesShareBase`, which normalizes the link
and matches it against all of a profile's addresses.

Reported from a Navidrome host whose guests could stream but never join;
confirmed against 1.50.0 by adding the scheme by hand.

* docs(changelog): note the Orbit join address fix (#1356)
This commit is contained in:
Psychotoxical
2026-07-27 17:06:42 +02:00
committed by GitHub
parent 9296c207ca
commit 3b391453d9
5 changed files with 82 additions and 8 deletions
+7
View File
@@ -192,6 +192,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Connecting a scrobbling service over a route it refuses — a VPN exit node, a proxy, a captive portal — failed with a generic network error and a raw decoder message attached. Psysonic now recognises a webpage arriving where data was expected and says so, pointing at the connection instead of leaving you to guess whether the app or the service is broken. Reported by zunoz on Discord.
### Orbit — joining a session no longer depends on how the server address was typed
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1356](https://github.com/Psychotoxical/psysonic/pull/1356)**, reported by Sakura on Discord
* Joining an invite could fail with "You don't have access to …" even for guests with a working account on that very server — they could browse and play music, only joining was refused. An invite carries the server address in its full form, while the check compared it against the address exactly as saved, so one entered without `http://` never matched.
* The same check now also recognises a server saved under its second address. Hosts who configure a local and a public address share invites pointing at the public one, which previously matched no account at all.
## [1.50.0]
@@ -10,6 +10,7 @@ import {
joinOrbitSession,
} from '@/features/orbit/utils/orbit';
import { switchActiveServer } from '@/utils/server/switchActiveServer';
import { normalizeServerBaseUrl, profileServesShareBase } from '@/lib/server/serverEndpoint';
import { useOrbitAccountPickerStore } from '@/features/orbit/store/orbitAccountPickerStore';
import { showToast } from '@/lib/dom/toast';
@@ -44,8 +45,7 @@ export default function OrbitJoinModal({ onClose }: Props) {
if (!parsed) { setError(t('orbit.joinErrInvalid')); return; }
const active = useAuthStore.getState().getActiveServer();
const activeUrl = (active?.url ?? '').replace(/\/+$/, '');
const wantUrl = parsed.serverBase.replace(/\/+$/, '');
const wantUrl = normalizeServerBaseUrl(parsed.serverBase);
setBusy(true);
try {
@@ -53,9 +53,9 @@ export default function OrbitJoinModal({ onClose }: Props) {
// Auto-switch to the link's server if the user has an account for it.
// Multiple candidates → picker modal. Any existing Orbit binding remains
// pinned to its original server until its own lifecycle ends.
if (activeUrl !== wantUrl) {
if (!(active && profileServesShareBase(active, wantUrl))) {
const candidates = useAuthStore.getState().servers
.filter(s => s.url.replace(/\/+$/, '') === wantUrl);
.filter(s => profileServesShareBase(s, wantUrl));
if (candidates.length === 0) {
setError(t('orbit.toastNoAccountForServer', { url: wantUrl }));
return;
@@ -5,6 +5,7 @@ import { useAuthStore } from '@/store/authStore';
import { extractNavidromePublicShareFromText } from '@/lib/share/navidromePublicShareUrl';
import { decodeSharePayloadFromText } from '@/lib/share/shareLink';
import { decodeServerMagicStringFromText } from '@/lib/server/serverMagicString';
import { normalizeServerBaseUrl, profileServesShareBase } from '@/lib/server/serverEndpoint';
import { applySharePastePayload, applySharePasteQueue } from '@/features/share/applySharePaste';
import { shareQueueServerContext } from '@/lib/share/shareServerOriginLabel';
import { showToast } from '@/lib/dom/toast';
@@ -119,8 +120,7 @@ export default function PasteClipboardHandler() {
(async () => {
const active = useAuthStore.getState().getActiveServer();
const activeUrl = (active?.url ?? '').replace(/\/+$/, '');
const wantUrl = orbit.serverBase.replace(/\/+$/, '');
const wantUrl = normalizeServerBaseUrl(orbit.serverBase);
// Auto-switch to the link's target server if the user has an
// account registered for it. No account → clear error. Multiple
@@ -128,9 +128,9 @@ export default function PasteClipboardHandler() {
// switch itself tears down any lingering orbit session (see
// switchActiveServer) so the join below starts clean.
let targetServerId = active?.id ?? '';
if (activeUrl !== wantUrl) {
if (!(active && profileServesShareBase(active, wantUrl))) {
const candidates = useAuthStore.getState().servers
.filter(s => s.url.replace(/\/+$/, '') === wantUrl);
.filter(s => profileServesShareBase(s, wantUrl));
if (candidates.length === 0) {
showToast(t('orbit.toastNoAccountForServer', { url: wantUrl }), 5000, 'warning');
return;
+46
View File
@@ -14,6 +14,7 @@ import {
isLanUrl,
normalizeServerBaseUrl,
pickReachableBaseUrl,
profileServesShareBase,
serverAddressEndpoints,
serverShareBaseUrl,
subscribeConnectCache,
@@ -170,6 +171,51 @@ describe('allNormalizedAddresses', () => {
});
});
describe('profileServesShareBase', () => {
// A share payload always carries the normalized address, so a profile whose
// stored url is not in that form still has to match — the reported Orbit
// join failure was exactly this (address typed without a scheme).
it('matches an address stored without a scheme', () => {
expect(
profileServesShareBase({ url: '203.0.113.7:4533' }, 'http://203.0.113.7:4533'),
).toBe(true);
});
it('matches a trailing-slash address', () => {
expect(
profileServesShareBase({ url: 'https://music.example.com/' }, 'https://music.example.com'),
).toBe(true);
});
it('matches on the alternate address, not just the primary', () => {
expect(
profileServesShareBase(
{ url: 'http://192.168.0.10:4533', alternateUrl: 'https://music.example.com' },
'https://music.example.com',
),
).toBe(true);
});
it('normalizes the share base too', () => {
expect(
profileServesShareBase({ url: 'https://music.example.com' }, 'https://music.example.com/'),
).toBe(true);
});
it('rejects a different server', () => {
expect(
profileServesShareBase(
{ url: 'https://music.example.com', alternateUrl: 'http://192.168.0.10:4533' },
'https://other.example.com',
),
).toBe(false);
});
it('rejects an empty share base', () => {
expect(profileServesShareBase({ url: 'https://music.example.com' }, '')).toBe(false);
});
});
describe('serverAddressEndpoints', () => {
it('returns a single local endpoint for a LAN-only profile', () => {
expect(
+21
View File
@@ -112,6 +112,27 @@ export function allNormalizedAddresses(
return result;
}
/**
* Does this profile serve the address a share link points at?
*
* Share payloads carry the **normalized** address (`serverShareBaseUrl`), which
* may be either of the profile's two addresses — for a dual-address profile the
* public one, since the recipient is not on the host's LAN. Comparing a raw
* `profile.url` against it therefore fails in two ways: it misses a match on
* `alternateUrl`, and it misses one where normalization changed the string at
* all (an address typed without a scheme is stored verbatim but shared as
* `http://…`). Both sides go through `normalizeServerBaseUrl` here so the check
* agrees with the link that was built.
*/
export function profileServesShareBase(
profile: Pick<ServerProfile, 'url' | 'alternateUrl'>,
shareBase: string,
): boolean {
const want = normalizeServerBaseUrl(shareBase);
if (!want) return false;
return allNormalizedAddresses(profile).includes(want);
}
/**
* Endpoint list for connect probing — LAN-first, stable within each class.
* Single-address profiles return one entry; dual-address returns up to two.