fix(orbit): outbox lost-update recovery and session hardening (#1159)

* fix(orbit): harden the guest outbox — recover lost suggestions, avoid a duplicate outbox

Two outbox robustness issues on the poll-based, non-atomic playlist
transport:

- Lost-update: a track a guest appends to its outbox can be wiped by a
  concurrent host sweep-clear before the host recorded it, leaving the
  suggestion lost AND stuck on "waiting on host" forever (it only clears
  once it reaches the host's play queue). The playlist read-modify-write
  can't be made atomic, so recover: re-send a pending suggestion the host
  hasn't recorded (absent from state.queue) past a grace window — the host
  dedupes by (user, trackId), so it's idempotent — and give up + toast past
  a 45s window so the row stops hanging. New pendingResend planner module
  with unit tests; suggest + tick share ensureTrackInOutbox.

- Duplicate outbox: a transient getPlaylists failure at join was swallowed
  to [], so the existing-outbox check missed and a second outbox was
  created. Distinguish "lookup failed" from "genuinely absent" and retry
  once before falling back to create.

New i18n key for the give-up toast across all locales.

* fix(orbit): reject non-http(s) schemes in share invites

normalizeShareServerUrl only prepends http:// when the server string doesn't
already start with "http", so an invite carrying a scheme like httpx:// or
https-phish:// slips through unchanged. Reject anything whose parsed protocol
isn't http(s) at parse time, before the known-server match. Add round-trip
tests covering the accepted and rejected schemes.

* fix(orbit): read exit-modal role fresh to avoid a stale keydown closure

The exit modal's Enter/Escape handler binds once per open (deps [isOpen]) and
closed over the role prop, so a role change while the modal was up would act
on a stale value. Read role from the store inside the action instead, and drop
the now-unused reactive subscription.

* docs(changelog): consolidate Orbit fixes into one block, add 1159
This commit is contained in:
Psychotoxical
2026-06-22 17:32:53 +02:00
committed by GitHub
parent 9b03949f34
commit 78a177b1bc
20 changed files with 292 additions and 44 deletions
+9 -33
View File
@@ -221,41 +221,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **Favorites** on All Albums uses the same `getStarred2` catalog path as the Favorites page instead of the empty sparse `album` table browse.
* Pre-index compilation filtering auto-paginates again in network page mode; offline library aggregates set `isCompilation` from track tags.
### Orbit — opening the app on another device could end a live session
### Orbit — session reliability fixes
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1155](https://github.com/Psychotoxical/psysonic/pull/1155)**
**By [@Psychotoxical](https://github.com/Psychotoxical), PRs [#1155](https://github.com/Psychotoxical/psysonic/pull/1155), [#1157](https://github.com/Psychotoxical/psysonic/pull/1157), [#1159](https://github.com/Psychotoxical/psysonic/pull/1159)**
* Starting Psysonic on a second device while you were hosting or in an Orbit session could delete that session's playlist, dropping your guests as if you had ended it. The start-up cleanup that clears leftover Orbit playlists now leaves a session that is still live on another device alone.
### Orbit — long sessions could quietly stop updating for guests
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1155](https://github.com/Psychotoxical/psysonic/pull/1155)**
* During a long Orbit session the host could silently stop broadcasting, leaving guests frozen until they timed out. The shared session data is now kept within its size limit (trimming the oldest suggestion history first), so the host keeps updating instead of stalling.
### Orbit — radio could add unrelated tracks for guests
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1155](https://github.com/Psychotoxical/psysonic/pull/1155)**
* A guest who joined while radio was playing could have unrelated radio tracks appended to their queue, drifting them out of sync with the host. Automatic radio top-up is now paused while you are in an Orbit session.
### Orbit — auto-approve could switch on unexpectedly in older sessions
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1157](https://github.com/Psychotoxical/psysonic/pull/1157)**
* In a long-running Orbit session created by an older build, toggling auto-shuffle could silently also turn on auto-approve, so guest suggestions started playing without the host approving them. The two settings are now independent again.
### Orbit — sessions could go over their guest limit
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1157](https://github.com/Psychotoxical/psysonic/pull/1157)**
* When several people joined at the same moment, a session could end up with more guests than its limit allowed. The host now enforces the limit, keeping the earliest joiners.
### Orbit — an accepted suggestion could occasionally be dropped
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1157](https://github.com/Psychotoxical/psysonic/pull/1157)**
* Under load the host's updates could overlap and overwrite each other, occasionally losing a guest suggestion that had just been picked up. Host updates are now serialised so none are lost.
* Opening Psysonic on a second device no longer deletes a session that is still live on another device.
* Long sessions keep updating for guests instead of silently stalling once the shared state grew too large.
* Radio no longer adds unrelated tracks to a guest's queue mid-session.
* Auto-shuffle and auto-approve are independent again — toggling one in an older session no longer flips the other.
* A session is kept within its guest limit even when several people join at once.
* Guest suggestions no longer get silently lost or stuck on "waiting on host": overlapping host updates are serialised, a lost suggestion is re-sent (with a notice if it still can't get through), and a flaky join no longer leaves a duplicate suggestion list on the server.
* Pasted invites are rejected unless they point at a normal http/https server address.
## [1.48.1] - 2026-06-15
+4 -2
View File
@@ -19,7 +19,6 @@ export default function OrbitExitModal() {
const { t } = useTranslation();
const phase = useOrbitStore(s => s.phase);
const errorMessage = useOrbitStore(s => s.errorMessage);
const role = useOrbitStore(s => s.role);
const sessionName = useOrbitStore(s => s.state?.name);
const hostName = useOrbitStore(s => s.state?.host);
@@ -31,7 +30,10 @@ export default function OrbitExitModal() {
const onOk = async () => {
try {
if (role === 'guest') await leaveOrbitSession();
// Read role fresh from the store — the keydown effect binds once per
// open (deps [isOpen]) and would otherwise act on a stale role if it
// changed while the modal was up.
if (useOrbitStore.getState().role === 'guest') await leaveOrbitSession();
else useOrbitStore.getState().reset();
} catch {
useOrbitStore.getState().reset();
+27
View File
@@ -9,7 +9,13 @@ import {
applyOrbitTransitionSettings,
saveGuestTransitionsOnce,
restoreGuestTransitions,
ensureTrackInOutbox,
planPendingResends,
forgetPendingSuggestion,
resetPendingResendState,
} from '../utils/orbit';
import { showToast } from '../utils/ui/toast';
import i18n from '../i18n';
import { estimateLivePosition, type OrbitState } from '../api/orbit';
import { pushOrbitEvent } from '../utils/orbitDiag';
import { useOrbitOutboxHeartbeat } from './useOrbitOutboxHeartbeat';
@@ -220,6 +226,26 @@ export function useOrbitGuest(): void {
for (const q of (state.playQueue ?? [])) landed.add(q.trackId);
if (state.currentTrack) landed.add(state.currentTrack.trackId);
useOrbitStore.getState().reconcilePendingSuggestions(landed);
landed.forEach(forgetPendingSuggestion);
// Mitigate the outbox lost-update race: a suggestion the host hasn't
// recorded (absent from state.queue, where every *received* submission
// lands) past a grace window was likely wiped by a racing sweep-clear
// — re-send it (the host dedupes, so this is idempotent). Give up +
// toast on ones that never land so the row doesn't hang forever.
const stillPending = useOrbitStore.getState().pendingSuggestions;
if (stillPending.length > 0 && outboxPlaylistId) {
const recorded = new Set(state.queue.map(q => q.trackId));
const plan = planPendingResends(stillPending, recorded);
for (const trackId of plan.resend) {
void ensureTrackInOutbox(outboxPlaylistId, trackId).catch(() => {});
}
if (plan.giveUp.length > 0) {
useOrbitStore.getState().reconcilePendingSuggestions(new Set(plan.giveUp));
plan.giveUp.forEach(forgetPendingSuggestion);
showToast(i18n.t('orbit.toastSuggestLost'), 3500, 'error');
}
}
}
// Host signalled session end: surface via `phase`, let the UI handle
@@ -374,6 +400,7 @@ export function useOrbitGuest(): void {
if (timer !== null) window.clearTimeout(timer);
// Leaving / session ended → give the user their own transition prefs back.
restoreGuestTransitions();
resetPendingResendState();
};
}, [active, sessionPlaylistId]);
+1
View File
@@ -143,6 +143,7 @@ export const orbit = {
settingShuffleNow: 'Jetzt mischen',
toastSuggested: '{{user}} hat „{{title}}" vorgeschlagen',
toastSuggestedMany: '{{count}} neue Vorschläge in der Warteschlange',
toastSuggestLost: 'Deine Suggestion hat den Host womöglich nicht erreicht',
toastShuffled: 'Warteschlange gemischt',
toastJoined: 'Session beigetreten',
toastLoginFirst: 'Erst anmelden, um einer Session beizutreten',
+1
View File
@@ -143,6 +143,7 @@ export const orbit = {
settingShuffleNow: 'Shuffle now',
toastSuggested: '{{user}} suggested "{{title}}"',
toastSuggestedMany: '{{count}} new suggestions in the queue',
toastSuggestLost: 'Your suggestion may not have reached the host',
toastShuffled: 'Queue shuffled',
toastJoined: 'Joined session',
toastLoginFirst: 'Log in before joining a session',
+1
View File
@@ -143,6 +143,7 @@ export const orbit = {
settingShuffleNow: 'Mezclar ahora',
toastSuggested: '{{user}} sugirió "{{title}}"',
toastSuggestedMany: '{{count}} nuevas sugerencias en la cola',
toastSuggestLost: 'Es posible que tu sugerencia no haya llegado al anfitrión',
toastShuffled: 'Cola mezclada',
toastJoined: 'Te has unido a la sesión',
toastLoginFirst: 'Inicia sesión antes de unirte a una sesión',
+1
View File
@@ -143,6 +143,7 @@ export const orbit = {
settingShuffleNow: 'Mélanger maintenant',
toastSuggested: '{{user}} a suggéré « {{title}} »',
toastSuggestedMany: '{{count}} nouvelles suggestions dans la file',
toastSuggestLost: 'Votre suggestion n\'a peut-être pas atteint l\'hôte',
toastShuffled: 'File mélangée',
toastJoined: 'Session rejointe',
toastLoginFirst: 'Connecte-toi avant de rejoindre une session',
+1
View File
@@ -143,6 +143,7 @@ export const orbit = {
settingShuffleNow: 'Keverés most',
toastSuggested: '{{user}} javasolta: „{{title}}"',
toastSuggestedMany: '{{count}} új javaslat a sorban',
toastSuggestLost: 'A javaslatod lehet, hogy nem jutott el a házigazdához',
toastShuffled: 'A sor megkeverve',
toastJoined: 'Csatlakoztál a munkamenethez',
toastLoginFirst: 'Jelentkezz be, mielőtt csatlakozol egy munkamenethez',
+1
View File
@@ -143,6 +143,7 @@ export const orbit = {
settingShuffleNow: '今すぐシャッフル',
toastSuggested: '{{user}} が "{{title}}" を提案しました',
toastSuggestedMany: 'キューに {{count}} 件の新しい提案',
toastSuggestLost: 'あなたの提案がホストに届かなかった可能性があります',
toastShuffled: 'キューをシャッフルしました',
toastJoined: 'セッションに参加しました',
toastLoginFirst: 'セッション参加前にログインしてください',
+1
View File
@@ -143,6 +143,7 @@ export const orbit = {
settingShuffleNow: 'Stokk nå',
toastSuggested: '{{user}} foreslo "{{title}}"',
toastSuggestedMany: '{{count}} nye forslag i køen',
toastSuggestLost: 'Forslaget ditt nådde kanskje ikke verten',
toastShuffled: 'Kø stokket',
toastJoined: 'Ble med i økt',
toastLoginFirst: 'Logg inn før du blir med i en økt',
+1
View File
@@ -143,6 +143,7 @@ export const orbit = {
settingShuffleNow: 'Nu shufflen',
toastSuggested: '{{user}} stelde "{{title}}" voor',
toastSuggestedMany: '{{count}} nieuwe suggesties in de wachtrij',
toastSuggestLost: 'Je suggestie heeft de host mogelijk niet bereikt',
toastShuffled: 'Wachtrij geshuffled',
toastJoined: 'Deelgenomen aan sessie',
toastLoginFirst: 'Log eerst in voordat je aan een sessie deelneemt',
+1
View File
@@ -143,6 +143,7 @@ export const orbit = {
settingShuffleNow: 'Amestecă acum',
toastSuggested: '{{user}} a sugerat "{{title}}"',
toastSuggestedMany: '{{count}} noi sugestii în coadă',
toastSuggestLost: 'Sugestia ta s-ar putea să nu fi ajuns la gazdă',
toastShuffled: 'Coadă amestecată',
toastJoined: 'A intrat în sesiune',
toastLoginFirst: 'Log in before joining a session',
+1
View File
@@ -145,6 +145,7 @@ export const orbit = {
settingShuffleNow: 'Перемешать сейчас',
toastSuggested: '{{user}} предложил «{{title}}»',
toastSuggestedMany: '{{count}} новых предложений в очереди',
toastSuggestLost: 'Возможно, ваше предложение не дошло до хоста',
toastShuffled: 'Очередь перемешана',
toastJoined: 'Присоединился к сессии',
toastLoginFirst: 'Войди, прежде чем присоединяться к сессии',
+1
View File
@@ -143,6 +143,7 @@ export const orbit = {
settingShuffleNow: '立即洗牌',
toastSuggested: '{{user}} 推荐了"{{title}}"',
toastSuggestedMany: '队列中有 {{count}} 个新建议',
toastSuggestLost: '你的建议可能未送达主持人',
toastShuffled: '队列已洗牌',
toastJoined: '已加入会话',
toastLoginFirst: '加入会话前请先登录',
+6
View File
@@ -73,6 +73,7 @@ export {
export {
approveOrbitSuggestion,
declineOrbitSuggestion,
ensureTrackInOutbox,
evaluateOrbitSuggestGate,
joinOrbitSession,
leaveOrbitSession,
@@ -81,5 +82,10 @@ export {
suggestOrbitTrack,
type OrbitSuggestGateReason,
} from './orbit/guest';
export {
forgetPendingSuggestion,
planPendingResends,
resetPendingResendState,
} from './orbit/pendingResend';
export { sweepGuestOutboxes } from './orbit/sweep';
export { cleanupOrphanedOrbitPlaylists } from './orbit/cleanup';
+30 -8
View File
@@ -10,6 +10,7 @@ import {
type OrbitState,
} from '../../api/orbit';
import { suggestionKey } from './helpers';
import { notePendingSuggestion } from './pendingResend';
import { findSessionPlaylistId, readOrbitState, writeOrbitHeartbeat } from './remote';
export class OrbitJoinError extends Error {
@@ -74,7 +75,18 @@ export async function joinOrbitSession(sid: string): Promise<OrbitState> {
// Guard against a stale outbox from a previous abandoned join attempt —
// if one exists under the same name, reuse its id instead of creating
// a duplicate (Navidrome allows duplicate names but it'd leak).
const existing = (await getPlaylists(true).catch(() => [])).find(p => p.name === outboxName);
// A *transient* getPlaylists failure must not make us create a duplicate
// outbox — distinguish "lookup failed" (undefined) from "genuinely absent"
// (null) and retry only on failure before falling back to create.
const lookupOutbox = async () => {
try {
return (await getPlaylists(true)).find(p => p.name === outboxName) ?? null;
} catch {
return undefined;
}
};
let existing = await lookupOutbox();
if (existing === undefined) existing = await lookupOutbox();
if (existing) {
outboxPlaylistId = existing.id;
} else {
@@ -165,17 +177,27 @@ export async function suggestOrbitTrack(trackId: string): Promise<void> {
if (role !== 'guest') throw new Error('Not joined to a session as a guest');
if (!outboxPlaylistId || !sessionId) throw new Error('No outbox bound');
// Read current outbox contents and append — createPlaylist.view with
// playlistId replaces songs wholesale, so we need to carry the existing
// list along.
const { songs } = await getPlaylist(outboxPlaylistId);
const nextIds = [...songs.map(s => s.id), trackId];
await updatePlaylist(outboxPlaylistId, nextIds, songs.length);
await ensureTrackInOutbox(outboxPlaylistId, trackId);
// Record the suggestion locally so the UI can surface it as "waiting on
// host" until the host's next sweep merges it into the shared queue.
// Drained by the guest tick's reconcilePendingSuggestions call.
// Drained by the guest tick's reconcilePendingSuggestions call; tracked for
// lost-update re-send by notePendingSuggestion.
useOrbitStore.getState().addPendingSuggestion(trackId);
notePendingSuggestion(trackId);
}
/**
* Append a track to an outbox playlist unless it's already there. Subsonic's
* playlist update replaces the song list wholesale, so we carry the existing
* ids along. Shared by the initial suggest and the guest tick's lost-update
* re-send (where a no-op append means the host already cleared + recorded it).
*/
export async function ensureTrackInOutbox(outboxPlaylistId: string, trackId: string): Promise<void> {
const { songs } = await getPlaylist(outboxPlaylistId);
const ids = songs.map(s => s.id);
if (ids.includes(trackId)) return;
await updatePlaylist(outboxPlaylistId, [...ids, trackId], ids.length);
}
/**
+68
View File
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
forgetPendingSuggestion,
notePendingSuggestion,
ORBIT_SUGGESTION_GIVE_UP_MS,
ORBIT_SUGGESTION_RESEND_GRACE_MS,
pendingResendTrackedCount,
planPendingResends,
resetPendingResendState,
} from './pendingResend';
const T0 = 1_000_000;
const GRACE = ORBIT_SUGGESTION_RESEND_GRACE_MS;
beforeEach(() => {
resetPendingResendState();
});
describe('planPendingResends', () => {
it('does nothing within the grace window', () => {
notePendingSuggestion('t1', T0);
const plan = planPendingResends(['t1'], new Set(), T0 + GRACE - 1);
expect(plan).toEqual({ resend: [], giveUp: [] });
});
it('re-sends once per grace window when the host has not recorded it', () => {
notePendingSuggestion('t1', T0);
// First window elapsed → one re-send.
expect(planPendingResends(['t1'], new Set(), T0 + GRACE + 1).resend).toEqual(['t1']);
// Still inside the second window → no further re-send.
expect(planPendingResends(['t1'], new Set(), T0 + GRACE + 100).resend).toEqual([]);
// Second window elapsed → re-send again.
expect(planPendingResends(['t1'], new Set(), T0 + GRACE * 2 + 1).resend).toEqual(['t1']);
});
it('leaves a host-recorded suggestion alone even past the grace window', () => {
notePendingSuggestion('t1', T0);
const plan = planPendingResends(['t1'], new Set(['t1']), T0 + GRACE * 3);
expect(plan).toEqual({ resend: [], giveUp: [] });
});
it('gives up once past the give-up window', () => {
notePendingSuggestion('t1', T0);
const plan = planPendingResends(['t1'], new Set(), T0 + ORBIT_SUGGESTION_GIVE_UP_MS + 1);
expect(plan.giveUp).toEqual(['t1']);
expect(plan.resend).toEqual([]);
});
it('seeds tracking for a suggestion it sees for the first time, acting next tick', () => {
// No prior note — first plan call just starts the clock.
const first = planPendingResends(['t1'], new Set(), T0);
expect(first).toEqual({ resend: [], giveUp: [] });
expect(pendingResendTrackedCount()).toBe(1);
// A grace window after that seed → re-send.
expect(planPendingResends(['t1'], new Set(), T0 + GRACE + 1).resend).toEqual(['t1']);
});
it('forget + reset clear tracking', () => {
notePendingSuggestion('t1', T0);
notePendingSuggestion('t2', T0);
expect(pendingResendTrackedCount()).toBe(2);
forgetPendingSuggestion('t1');
expect(pendingResendTrackedCount()).toBe(1);
resetPendingResendState();
expect(pendingResendTrackedCount()).toBe(0);
});
});
+98
View File
@@ -0,0 +1,98 @@
/**
* Guest-side mitigation for the outbox lost-update race.
*
* A track a guest appends to its outbox can be wiped by a concurrent host
* sweep-clear (`updatePlaylist(outbox, [], n)`) before the host actually
* recorded it the suggestion is lost AND stays stuck on "waiting on host"
* forever, because the guest only clears it once it reaches the host's play
* queue. The read-modify-write on a Subsonic playlist can't be made atomic, so
* instead we recover:
*
* - A pending suggestion the host has NOT recorded (absent from
* `state.queue`, the suggestion history every received submission lands in)
* past a grace window is re-sent. The host dedupes by (user, trackId), so
* re-sending is idempotent a slow-but-not-lost suggestion just no-ops.
* - Past the give-up window it is dropped so the UI stops hanging (host gone,
* guest muted, or over the cap).
*
* Recorded-but-not-yet-merged suggestions (manual-approval mode) are left
* alone the host has them, they're legitimately waiting.
*/
/** ~3 host ticks: long enough that a normal-latency suggestion isn't re-sent. */
export const ORBIT_SUGGESTION_RESEND_GRACE_MS = 7_500;
/** Stop re-sending / waiting once a suggestion is this old and still unrecorded. */
export const ORBIT_SUGGESTION_GIVE_UP_MS = 45_000;
interface PendingMeta {
addedAt: number;
resends: number;
}
// Module-level so it survives guest-hook remounts within a single session.
const meta = new Map<string, PendingMeta>();
/** Start tracking a suggestion the moment it's submitted. Idempotent. */
export function notePendingSuggestion(trackId: string, now: number = Date.now()): void {
if (!meta.has(trackId)) meta.set(trackId, { addedAt: now, resends: 0 });
}
/** Stop tracking a suggestion (it landed, was given up, or the user left). */
export function forgetPendingSuggestion(trackId: string): void {
meta.delete(trackId);
}
/** Clear all tracking — call on leaving / ending a session. */
export function resetPendingResendState(): void {
meta.clear();
}
/** Test-only: number of suggestions currently tracked. */
export function pendingResendTrackedCount(): number {
return meta.size;
}
export interface PendingResendPlan {
/** Re-append these to the outbox — the host hasn't recorded them yet. */
resend: string[];
/** Drop these from the pending UI — they never reached the host. */
giveUp: string[];
}
/**
* Decide which still-pending suggestions to re-send and which to give up on.
* `recordedByHost` = trackIds in the host's `state.queue` (received, so not
* lost leave them alone even if not yet merged into the play queue).
*
* Mutates the internal retry bookkeeping; returns the actions for this tick.
*/
export function planPendingResends(
pending: readonly string[],
recordedByHost: ReadonlySet<string>,
now: number = Date.now(),
): PendingResendPlan {
const resend: string[] = [];
const giveUp: string[] = [];
for (const trackId of pending) {
let m = meta.get(trackId);
if (!m) {
// First time we see it here (e.g. submitted before notePending ran) —
// start the clock, act next tick.
m = { addedAt: now, resends: 0 };
meta.set(trackId, m);
continue;
}
if (recordedByHost.has(trackId)) continue; // host has it — not lost
const age = now - m.addedAt;
if (age > ORBIT_SUGGESTION_GIVE_UP_MS) {
giveUp.push(trackId);
continue;
}
// One re-send per grace window, paced by how many we've already done.
if (age > ORBIT_SUGGESTION_RESEND_GRACE_MS * (m.resends + 1)) {
m.resends += 1;
resend.push(trackId);
}
}
return { resend, giveUp };
}
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { buildOrbitShareLink, parseOrbitShareLink } from './shareLink';
describe('parseOrbitShareLink', () => {
it('round-trips an https invite', () => {
const link = buildOrbitShareLink('https://music.example.com', 'aaaa1111');
expect(parseOrbitShareLink(link)).toEqual({
serverBase: 'https://music.example.com',
sid: 'aaaa1111',
});
});
it('accepts a plain http server', () => {
const link = buildOrbitShareLink('http://192.168.1.10:4533', 'bbbb2222');
expect(parseOrbitShareLink(link)?.serverBase).toBe('http://192.168.1.10:4533');
});
it('rejects http-prefixed but non-http(s) schemes that slip past url normalization', () => {
// normalizeShareServerUrl only prepends http:// when the string doesn't
// already start with "http", so these reach the parser unchanged — the
// protocol allow-list is what rejects them.
for (const bad of ['httpx://evil.example', 'https-phish://evil.example']) {
const link = buildOrbitShareLink(bad, 'cccc3333');
expect(parseOrbitShareLink(link)).toBeNull();
}
});
it('rejects a non-URL server and empty input', () => {
expect(parseOrbitShareLink(buildOrbitShareLink('not a url', 'dddd4444'))).toBeNull();
expect(parseOrbitShareLink('')).toBeNull();
expect(parseOrbitShareLink('garbage-not-a-share-string')).toBeNull();
});
});
+5 -1
View File
@@ -16,7 +16,11 @@ export function parseOrbitShareLink(text: string): OrbitShareLink | null {
if (!text) return null;
const payload = decodeOrbitSharePayloadFromText(text);
if (!payload) return null;
try { new URL(payload.srv); } catch { return null; }
let url: URL;
try { url = new URL(payload.srv); } catch { return null; }
// Only http(s) — a pasted invite must not smuggle file:/javascript:/etc.
// Neutralized downstream by the known-server match too, but reject early.
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
return { serverBase: payload.srv, sid: payload.sid };
}