mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
This commit is contained in:
@@ -26,7 +26,7 @@ vi.mock('../utils/network/subsonicNetworkGuard', () => ({
|
||||
}));
|
||||
|
||||
import axios from 'axios';
|
||||
import { pingWithCredentials, ping } from './subsonic';
|
||||
import { pingWithCredentials, pingWithCredentialsForProfile, ping } from './subsonic';
|
||||
import { getAlbumInfo2 } from './subsonicAlbumInfo';
|
||||
import { getStarred } from './subsonicStarRating';
|
||||
import { search } from './subsonicSearch';
|
||||
@@ -440,3 +440,39 @@ describe('pingWithCredentials — explicit URL/credentials path', () => {
|
||||
expect(r.openSubsonic).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pingWithCredentialsForProfile — custom gate headers', () => {
|
||||
it('sends resolved custom headers on the ping request', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({ type: 'navidrome' }));
|
||||
await pingWithCredentialsForProfile(
|
||||
{
|
||||
url: 'https://music.example.com',
|
||||
alternateUrl: 'http://192.168.0.10:4533',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||
customHeadersApplyTo: 'public',
|
||||
},
|
||||
'https://music.example.com',
|
||||
);
|
||||
const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record<string, string> };
|
||||
expect(config.headers?.['CF-Access-Client-Secret']).toBe('gate-secret');
|
||||
});
|
||||
|
||||
it('omits gate headers when probing the LAN endpoint with applyTo=public', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okResponse({}));
|
||||
await pingWithCredentialsForProfile(
|
||||
{
|
||||
url: 'https://music.example.com',
|
||||
alternateUrl: 'http://192.168.0.10:4533',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||
customHeadersApplyTo: 'public',
|
||||
},
|
||||
'http://192.168.0.10:4533',
|
||||
);
|
||||
const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record<string, string> };
|
||||
expect(config.headers?.['CF-Access-Client-Secret']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,11 +31,26 @@ describe('scheduleInstantMixProbeForServer (idempotency)', () => {
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(['sonicSimilarity']);
|
||||
reset();
|
||||
useAuthStore.setState({
|
||||
servers: [{
|
||||
id: SID,
|
||||
name: 'Probe',
|
||||
url: 'https://music.example.com',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||
customHeadersApplyTo: 'public',
|
||||
}],
|
||||
} as never);
|
||||
});
|
||||
|
||||
it('probes once, caches the result, then skips on the next poll', async () => {
|
||||
scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[3]).toMatchObject({
|
||||
url: 'https://music.example.com',
|
||||
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||
});
|
||||
await flush();
|
||||
expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('present');
|
||||
|
||||
|
||||
+56
-2
@@ -1,6 +1,9 @@
|
||||
import axios from 'axios';
|
||||
import md5 from 'md5';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import type { ServerProfile } from '../store/authStoreTypes';
|
||||
import { headersForServerRequest } from '../utils/server/serverHttpHeaders';
|
||||
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
|
||||
import {
|
||||
type InstantMixProbeResult,
|
||||
type SubsonicServerIdentity,
|
||||
@@ -19,6 +22,7 @@ import {
|
||||
api,
|
||||
apiWithCredentials,
|
||||
secureRandomSalt,
|
||||
type ServerHttpHeaderProfile,
|
||||
} from './subsonicClient';
|
||||
import type { PingWithCredentialsResult, SubsonicSong } from './subsonicTypes';
|
||||
|
||||
@@ -61,6 +65,47 @@ export async function pingWithCredentials(
|
||||
}
|
||||
}
|
||||
|
||||
/** Profile-aware ping for connect probe — attaches custom headers per endpoint. */
|
||||
export async function pingWithCredentialsForProfile(
|
||||
profile: Pick<
|
||||
ServerProfile,
|
||||
'url' | 'alternateUrl' | 'username' | 'password' | 'customHeaders' | 'customHeadersApplyTo'
|
||||
>,
|
||||
endpointBaseUrl: string,
|
||||
): Promise<PingWithCredentialsResult> {
|
||||
try {
|
||||
const base = endpointBaseUrl.startsWith('http')
|
||||
? endpointBaseUrl.replace(/\/$/, '')
|
||||
: `http://${endpointBaseUrl.replace(/\/$/, '')}`;
|
||||
const salt = secureRandomSalt();
|
||||
const token = md5(profile.password + salt);
|
||||
const resp = await axios.get(`${base}/rest/ping.view`, {
|
||||
params: {
|
||||
u: profile.username,
|
||||
t: token,
|
||||
s: salt,
|
||||
v: '1.16.1',
|
||||
c: SUBSONIC_CLIENT,
|
||||
f: 'json',
|
||||
},
|
||||
headers: headersForServerRequest(profile, endpointBaseUrl),
|
||||
paramsSerializer: { indexes: null },
|
||||
timeout: 15000,
|
||||
});
|
||||
const data = resp.data?.['subsonic-response'];
|
||||
const ok = data?.status === 'ok';
|
||||
return {
|
||||
ok,
|
||||
type: typeof data?.type === 'string' ? data.type : undefined,
|
||||
serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined,
|
||||
openSubsonic: data?.openSubsonic === true,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[psysonic] pingWithCredentialsForProfile failed:', endpointBaseUrl, err);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
const INSTANT_MIX_PROBE_RANDOM_SIZE = 8;
|
||||
const INSTANT_MIX_PROBE_SIMILAR_COUNT = 12;
|
||||
const INSTANT_MIX_PROBE_MAX_TRACKS = 4;
|
||||
@@ -74,6 +119,7 @@ export async function probeInstantMixWithCredentials(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
headerProfile?: ServerHttpHeaderProfile,
|
||||
): Promise<InstantMixProbeResult> {
|
||||
try {
|
||||
const data = await apiWithCredentials<{ randomSongs: { song: SubsonicSong | SubsonicSong[] } }>(
|
||||
@@ -83,6 +129,7 @@ export async function probeInstantMixWithCredentials(
|
||||
'getRandomSongs.view',
|
||||
{ size: INSTANT_MIX_PROBE_RANDOM_SIZE, _t: Date.now() },
|
||||
12000,
|
||||
headerProfile,
|
||||
);
|
||||
const raw = data.randomSongs?.song;
|
||||
const songs: SubsonicSong[] = !raw ? [] : Array.isArray(raw) ? raw : [raw];
|
||||
@@ -98,6 +145,7 @@ export async function probeInstantMixWithCredentials(
|
||||
'getSimilarSongs.view',
|
||||
{ id: song.id, count: INSTANT_MIX_PROBE_SIMILAR_COUNT },
|
||||
12000,
|
||||
headerProfile,
|
||||
);
|
||||
const sRaw = simData.similarSongs?.song;
|
||||
const list: SubsonicSong[] = !sRaw ? [] : Array.isArray(sRaw) ? sRaw : [sRaw];
|
||||
@@ -138,6 +186,7 @@ export function scheduleInstantMixProbeForServer(
|
||||
const ctx = buildCapabilityContext(identity);
|
||||
const probeIds = neededProbeIds(SERVER_CAPABILITY_CATALOG, ctx);
|
||||
const store = useAuthStore.getState();
|
||||
const headerProfile = findServerByIdOrIndexKey(serverId);
|
||||
|
||||
if (probeIds.has(PROBE_OPENSUBSONIC_EXTENSIONS)) {
|
||||
// One `getOpenSubsonicExtensions` fetch answers every extension-gated feature.
|
||||
@@ -153,7 +202,12 @@ export function scheduleInstantMixProbeForServer(
|
||||
const audiomuseStale = audiomuseEligible && (cached === undefined || cached === 'error');
|
||||
if (force || listMissing || audiomuseStale) {
|
||||
if (audiomuseEligible) store.setAudiomusePluginProbe(serverId, 'probing');
|
||||
void fetchOpenSubsonicExtensionsWithCredentials(serverUrl, username, password).then(extensions => {
|
||||
void fetchOpenSubsonicExtensionsWithCredentials(
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
headerProfile,
|
||||
).then(extensions => {
|
||||
const st = useAuthStore.getState();
|
||||
if (extensions === null) {
|
||||
if (audiomuseEligible) st.setAudiomusePluginProbe(serverId, 'error');
|
||||
@@ -170,7 +224,7 @@ export function scheduleInstantMixProbeForServer(
|
||||
if (probeIds.has(PROBE_LEGACY_INSTANT_MIX)) {
|
||||
const cached = store.instantMixProbeByServer[serverId];
|
||||
if (force || cached === undefined || cached === 'error') {
|
||||
void probeInstantMixWithCredentials(serverUrl, username, password).then(result =>
|
||||
void probeInstantMixWithCredentials(serverUrl, username, password, headerProfile).then(result =>
|
||||
useAuthStore.getState().setInstantMixProbe(serverId, result),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,17 @@ import { version } from '../../package.json';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import type { ServerProfile } from '../store/authStoreTypes';
|
||||
import { connectBaseUrlForServer } from '../utils/server/serverEndpoint';
|
||||
import { headersForServerRequest } from '../utils/server/serverHttpHeaders';
|
||||
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/server/serverLookup';
|
||||
|
||||
export const SUBSONIC_CLIENT = `psysonic/${version}`;
|
||||
|
||||
/** Subset of `ServerProfile` needed to attach gate headers on credential-based REST calls. */
|
||||
export type ServerHttpHeaderProfile = Pick<
|
||||
ServerProfile,
|
||||
'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo'
|
||||
>;
|
||||
|
||||
export function secureRandomSalt(): string {
|
||||
const buf = new Uint8Array(8);
|
||||
crypto.getRandomValues(buf);
|
||||
@@ -32,10 +39,13 @@ export async function apiWithCredentials<T>(
|
||||
endpoint: string,
|
||||
extra: Record<string, unknown> = {},
|
||||
timeout = 15000,
|
||||
headerProfile?: ServerHttpHeaderProfile,
|
||||
): Promise<T> {
|
||||
const params = { ...getAuthParams(username, password), ...extra };
|
||||
const headers = headerProfile ? headersForServerRequest(headerProfile, serverUrl) : {};
|
||||
const resp = await axios.get(`${restBaseFromUrl(serverUrl)}/${endpoint}`, {
|
||||
params,
|
||||
headers,
|
||||
paramsSerializer: { indexes: null },
|
||||
timeout,
|
||||
});
|
||||
@@ -78,6 +88,7 @@ export async function apiForServer<T>(
|
||||
endpoint,
|
||||
extra,
|
||||
timeout,
|
||||
server,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,8 +99,13 @@ export async function api<T>(
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const { baseUrl, params } = getClient();
|
||||
const server = useAuthStore.getState().getActiveServer();
|
||||
const connectBase = useAuthStore.getState().getBaseUrl();
|
||||
const headers =
|
||||
server && connectBase ? headersForServerRequest(server, connectBase) : {};
|
||||
const resp = await axios.get(`${baseUrl}/${endpoint}`, {
|
||||
params: { ...params, ...extra },
|
||||
headers,
|
||||
paramsSerializer: { indexes: null },
|
||||
timeout,
|
||||
signal,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiWithCredentials } from './subsonicClient';
|
||||
import { apiWithCredentials, type ServerHttpHeaderProfile } from './subsonicClient';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes';
|
||||
|
||||
export async function getSongWithCredentials(
|
||||
@@ -6,6 +6,7 @@ export async function getSongWithCredentials(
|
||||
username: string,
|
||||
password: string,
|
||||
id: string,
|
||||
headerProfile?: ServerHttpHeaderProfile,
|
||||
): Promise<SubsonicSong | null> {
|
||||
try {
|
||||
const data = await apiWithCredentials<{ song: SubsonicSong }>(
|
||||
@@ -14,6 +15,8 @@ export async function getSongWithCredentials(
|
||||
password,
|
||||
'getSong.view',
|
||||
{ id },
|
||||
15000,
|
||||
headerProfile,
|
||||
);
|
||||
return data.song ?? null;
|
||||
} catch {
|
||||
@@ -26,6 +29,7 @@ export async function getAlbumWithCredentials(
|
||||
username: string,
|
||||
password: string,
|
||||
id: string,
|
||||
headerProfile?: ServerHttpHeaderProfile,
|
||||
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> {
|
||||
const data = await apiWithCredentials<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>(
|
||||
serverUrl,
|
||||
@@ -33,6 +37,8 @@ export async function getAlbumWithCredentials(
|
||||
password,
|
||||
'getAlbum.view',
|
||||
{ id },
|
||||
15000,
|
||||
headerProfile,
|
||||
);
|
||||
const { song, ...album } = data.album;
|
||||
return { album, songs: song ?? [] };
|
||||
@@ -43,6 +49,7 @@ export async function getArtistWithCredentials(
|
||||
username: string,
|
||||
password: string,
|
||||
id: string,
|
||||
headerProfile?: ServerHttpHeaderProfile,
|
||||
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
|
||||
const data = await apiWithCredentials<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>(
|
||||
serverUrl,
|
||||
@@ -50,6 +57,8 @@ export async function getArtistWithCredentials(
|
||||
password,
|
||||
'getArtist.view',
|
||||
{ id },
|
||||
15000,
|
||||
headerProfile,
|
||||
);
|
||||
const { album, ...artist } = data.artist;
|
||||
return { artist, albums: album ?? [] };
|
||||
|
||||
@@ -69,4 +69,15 @@ describe('fetchOpenSubsonicExtensionsWithCredentials', () => {
|
||||
fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('sends custom gate headers when a header profile is supplied', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue(okExtensions([]));
|
||||
await fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p', {
|
||||
url: 'https://music.test',
|
||||
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }],
|
||||
customHeadersApplyTo: 'public',
|
||||
});
|
||||
const config = vi.mocked(axios.get).mock.calls[0]?.[1] as { headers?: Record<string, string> };
|
||||
expect(config.headers?.['CF-Access-Client-Secret']).toBe('gate-secret');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiWithCredentials } from './subsonicClient';
|
||||
import { apiWithCredentials, type ServerHttpHeaderProfile } from './subsonicClient';
|
||||
|
||||
export interface OpenSubsonicExtension {
|
||||
name: string;
|
||||
@@ -30,6 +30,7 @@ export async function fetchOpenSubsonicExtensionsWithCredentials(
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
headerProfile?: ServerHttpHeaderProfile,
|
||||
): Promise<string[] | null> {
|
||||
try {
|
||||
const data = await apiWithCredentials<{ openSubsonicExtensions?: unknown }>(
|
||||
@@ -39,6 +40,7 @@ export async function fetchOpenSubsonicExtensionsWithCredentials(
|
||||
'getOpenSubsonicExtensions.view',
|
||||
{},
|
||||
12000,
|
||||
headerProfile,
|
||||
);
|
||||
return parseOpenSubsonicExtensions(data.openSubsonicExtensions).map(ext => ext.name);
|
||||
} catch {
|
||||
|
||||
@@ -16,6 +16,12 @@ vi.mock('@/api/subsonic', () => ({
|
||||
serverVersion: '0.55.0',
|
||||
openSubsonic: true,
|
||||
})),
|
||||
pingWithCredentialsForProfile: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: 'navidrome',
|
||||
serverVersion: '0.55.0',
|
||||
openSubsonic: true,
|
||||
})),
|
||||
scheduleInstantMixProbeForServer: vi.fn(),
|
||||
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
|
||||
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
|
||||
|
||||
@@ -143,3 +143,33 @@ describe('AddServerForm — dual-address behaviour', () => {
|
||||
expect(arg).not.toHaveProperty('shareUsesLocalUrl');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AddServerForm — custom HTTP headers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('includes configured custom headers on save', async () => {
|
||||
const onSave = vi.fn();
|
||||
renderWithProviders(<AddServerForm onSave={onSave} onCancel={vi.fn()} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
await user.type(inputs[1]!, 'https://music.example.com');
|
||||
await user.type(inputs[3]!, 'tester');
|
||||
await user.type(screen.getByPlaceholderText('••••••••'), 'pw');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /custom http headers/i }));
|
||||
const headerNameInputs = screen.getAllByPlaceholderText(/header name/i);
|
||||
const headerValueInputs = screen.getAllByPlaceholderText(/header value/i);
|
||||
await user.type(headerNameInputs[0]!, 'CF-Access-Client-Secret');
|
||||
await user.type(headerValueInputs[0]!, 'gate-secret');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Add' }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
const arg = onSave.mock.calls[0]![0];
|
||||
expect(arg.customHeaders).toEqual([{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }]);
|
||||
expect(arg.customHeadersApplyTo).toBe('public');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||
import type { CustomHeaderEntry, CustomHeadersApplyTo, ServerProfile } from '../../store/authStoreTypes';
|
||||
import { showToast } from '../../utils/ui/toast';
|
||||
import {
|
||||
DEFAULT_CUSTOM_HEADERS_APPLY_TO,
|
||||
validateCustomHeaders,
|
||||
} from '../../utils/server/serverHttpHeaders';
|
||||
import {
|
||||
decodeServerMagicString,
|
||||
encodeServerMagicString,
|
||||
@@ -19,6 +23,9 @@ type FormState = {
|
||||
shareUsesLocalUrl: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
customHeaders: CustomHeaderEntry[];
|
||||
customHeadersApplyTo: CustomHeadersApplyTo;
|
||||
customHeadersOpen: boolean;
|
||||
};
|
||||
|
||||
/** Hostname for the DNS-based form hint, or null when the input is a literal IP. */
|
||||
@@ -62,6 +69,12 @@ export function AddServerForm({
|
||||
shareUsesLocalUrl: editingServer.shareUsesLocalUrl ?? false,
|
||||
username: editingServer.username,
|
||||
password: editingServer.password,
|
||||
customHeaders: editingServer.customHeaders?.length
|
||||
? editingServer.customHeaders.map(h => ({ ...h }))
|
||||
: [{ name: '', value: '' }],
|
||||
customHeadersApplyTo:
|
||||
editingServer.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO,
|
||||
customHeadersOpen: Boolean(editingServer.customHeaders?.length),
|
||||
}
|
||||
: {
|
||||
name: '',
|
||||
@@ -70,6 +83,9 @@ export function AddServerForm({
|
||||
shareUsesLocalUrl: false,
|
||||
username: '',
|
||||
password: '',
|
||||
customHeaders: [{ name: '', value: '' }],
|
||||
customHeadersApplyTo: DEFAULT_CUSTOM_HEADERS_APPLY_TO,
|
||||
customHeadersOpen: false,
|
||||
},
|
||||
);
|
||||
const [magicString, setMagicString] = useState('');
|
||||
@@ -175,6 +191,20 @@ export function AddServerForm({
|
||||
return true;
|
||||
};
|
||||
|
||||
const customHeadersPayload = (): Pick<
|
||||
ServerProfile,
|
||||
'customHeaders' | 'customHeadersApplyTo'
|
||||
> => {
|
||||
const rows = form.customHeaders
|
||||
.map(h => ({ name: h.name.trim(), value: h.value }))
|
||||
.filter(h => h.name || h.value);
|
||||
if (!rows.length) return {};
|
||||
return {
|
||||
customHeaders: rows,
|
||||
customHeadersApplyTo: form.customHeadersApplyTo,
|
||||
};
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const ms = magicString.trim();
|
||||
if (ms) {
|
||||
@@ -193,6 +223,7 @@ export function AddServerForm({
|
||||
url: decoded.url,
|
||||
username: decoded.username,
|
||||
password: decoded.password,
|
||||
...customHeadersPayload(),
|
||||
...(altDecoded
|
||||
? {
|
||||
alternateUrl: altDecoded,
|
||||
@@ -205,6 +236,15 @@ export function AddServerForm({
|
||||
if (!form.url.trim()) return;
|
||||
if (!validateAddresses()) return;
|
||||
|
||||
const headerValidation = validateCustomHeaders(
|
||||
form.customHeaders.filter(h => h.name.trim() || h.value),
|
||||
);
|
||||
if (!headerValidation.ok) {
|
||||
const first = headerValidation.fieldErrors[0];
|
||||
showToast(t(first.messageKey, { defaultValue: first.messageKey }), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const altTrimmed = form.alternateUrl.trim();
|
||||
// If the user clears the second address, strip the share-flag as well so
|
||||
// we don't leave a dangling preference (spec §5.3 last row).
|
||||
@@ -213,6 +253,7 @@ export function AddServerForm({
|
||||
url: form.url.trim(),
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
...customHeadersPayload(),
|
||||
...(altTrimmed
|
||||
? {
|
||||
alternateUrl: altTrimmed,
|
||||
@@ -338,6 +379,108 @@ export function AddServerForm({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 13, padding: '4px 0' }}
|
||||
onClick={() => setForm(f => ({ ...f, customHeadersOpen: !f.customHeadersOpen }))}
|
||||
>
|
||||
{form.customHeadersOpen ? '▾' : '▸'} {t('settings.customHeadersTitle')}
|
||||
</button>
|
||||
{form.customHeadersOpen && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<p style={{ fontSize: 11, opacity: 0.75, margin: '0 0 8px' }}>
|
||||
{t('settings.customHeadersHelp')}
|
||||
</p>
|
||||
{form.customHeaders.map((row, index) => (
|
||||
<div key={index} className="form-row" style={{ marginBottom: 6, gap: 8 }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={row.name}
|
||||
onChange={e => {
|
||||
const name = e.target.value;
|
||||
setForm(f => {
|
||||
const customHeaders = f.customHeaders.map((h, i) =>
|
||||
i === index ? { ...h, name } : h,
|
||||
);
|
||||
return { ...f, customHeaders };
|
||||
});
|
||||
}}
|
||||
placeholder={t('settings.customHeadersNamePlaceholder')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={row.value}
|
||||
onChange={e => {
|
||||
const value = e.target.value;
|
||||
setForm(f => ({
|
||||
...f,
|
||||
customHeaders: f.customHeaders.map((h, i) =>
|
||||
i === index ? { ...h, value } : h,
|
||||
),
|
||||
}));
|
||||
}}
|
||||
placeholder={t('settings.customHeadersValuePlaceholder')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
aria-label={t('settings.customHeadersRemoveRow')}
|
||||
onClick={() =>
|
||||
setForm(f => ({
|
||||
...f,
|
||||
customHeaders:
|
||||
f.customHeaders.length <= 1
|
||||
? [{ name: '', value: '' }]
|
||||
: f.customHeaders.filter((_, i) => i !== index),
|
||||
}))
|
||||
}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, marginBottom: 8 }}
|
||||
onClick={() =>
|
||||
setForm(f => ({
|
||||
...f,
|
||||
customHeaders: [...f.customHeaders, { name: '', value: '' }],
|
||||
}))
|
||||
}
|
||||
>
|
||||
{t('settings.customHeadersAddRow')}
|
||||
</button>
|
||||
<fieldset
|
||||
disabled={!form.customHeaders.some(h => h.name.trim() || h.value)}
|
||||
style={{ border: 'none', padding: 0, margin: 0 }}
|
||||
>
|
||||
<legend style={{ fontSize: 12, marginBottom: 4 }}>{t('settings.customHeadersApplyTo')}</legend>
|
||||
{(['public', 'local', 'both'] as const).map(kind => (
|
||||
<label key={kind} style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="customHeadersApplyTo"
|
||||
checked={form.customHeadersApplyTo === kind}
|
||||
onChange={() => setForm(f => ({ ...f, customHeadersApplyTo: kind }))}
|
||||
/>{' '}
|
||||
{t(`settings.customHeadersApplyTo_${kind}`)}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<p style={{ fontSize: 11, opacity: 0.65, marginTop: 6 }}>
|
||||
{t('settings.customHeadersNotInShare')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isEdit && (
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ fontSize: 13 }}>{t('login.orMagicString')}</label>
|
||||
|
||||
@@ -12,7 +12,11 @@ import { bootstrapIndexedServer } from '../../utils/library/librarySession';
|
||||
import { useLibraryIndexSync } from '../../hooks/useLibraryIndexSync';
|
||||
import ServerLibraryIndexControls from './ServerLibraryIndexControls';
|
||||
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic';
|
||||
import { pingWithCredentialsForProfile, scheduleInstantMixProbeForServer } from '../../api/subsonic';
|
||||
import {
|
||||
clearServerHttpContext,
|
||||
syncServerHttpContextForProfile,
|
||||
} from '../../utils/server/syncServerHttpContext';
|
||||
import { useDragDrop } from '../../contexts/DragDropContext';
|
||||
import { type ServerMagicPayload } from '../../utils/server/serverMagicString';
|
||||
import { ensureConnectUrlResolved, invalidateReachableEndpointCache } from '../../utils/server/serverEndpoint';
|
||||
@@ -191,6 +195,7 @@ export function ServersTab({
|
||||
|
||||
auth.removeServer(server.id);
|
||||
try {
|
||||
await clearServerHttpContext(server);
|
||||
await librarySyncClearSession(server.id);
|
||||
if (purgeLibrary) {
|
||||
await libraryDeleteServerData(server.id);
|
||||
@@ -238,7 +243,12 @@ export function ServersTab({
|
||||
// straight to the legacy ping (which is also the connect-test).
|
||||
if (data.alternateUrl) {
|
||||
const verify = await verifySameServerEndpoints(
|
||||
{ url: data.url, alternateUrl: data.alternateUrl },
|
||||
{
|
||||
url: data.url,
|
||||
alternateUrl: data.alternateUrl,
|
||||
customHeaders: data.customHeaders,
|
||||
customHeadersApplyTo: data.customHeadersApplyTo,
|
||||
},
|
||||
data.username,
|
||||
data.password,
|
||||
);
|
||||
@@ -247,7 +257,7 @@ export function ServersTab({
|
||||
return;
|
||||
}
|
||||
}
|
||||
const ping = await pingWithCredentials(data.url, data.username, data.password);
|
||||
const ping = await pingWithCredentialsForProfile(data, data.url);
|
||||
if (ping.ok) {
|
||||
const id = auth.addServer(data);
|
||||
const identity = {
|
||||
@@ -259,7 +269,10 @@ export function ServersTab({
|
||||
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity, true);
|
||||
setConnStatus(s => ({ ...s, [id]: 'ok' }));
|
||||
const added = useAuthStore.getState().servers.find(s => s.id === id);
|
||||
if (added) void bootstrapIndexedServer(added);
|
||||
if (added) {
|
||||
void syncServerHttpContextForProfile(added);
|
||||
void bootstrapIndexedServer(added);
|
||||
}
|
||||
} else {
|
||||
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
|
||||
}
|
||||
@@ -323,7 +336,12 @@ export function ServersTab({
|
||||
if (dualAddressChanged) {
|
||||
setConnStatus(s => ({ ...s, [id]: 'testing' }));
|
||||
const verify = await verifySameServerEndpoints(
|
||||
{ url: data.url, alternateUrl: data.alternateUrl },
|
||||
{
|
||||
url: data.url,
|
||||
alternateUrl: data.alternateUrl,
|
||||
customHeaders: data.customHeaders,
|
||||
customHeadersApplyTo: data.customHeadersApplyTo,
|
||||
},
|
||||
data.username,
|
||||
data.password,
|
||||
);
|
||||
@@ -335,12 +353,13 @@ export function ServersTab({
|
||||
|
||||
setEditingServerId(null);
|
||||
auth.updateServer(id, data);
|
||||
const updated = useAuthStore.getState().servers.find(s => s.id === id);
|
||||
if (updated) void syncServerHttpContextForProfile(updated);
|
||||
// Profile edited → any cached sticky connect URL for this id may now be
|
||||
// stale (credentials may have changed, alternate may have been added).
|
||||
invalidateReachableEndpointCache(id);
|
||||
setConnStatus(s => ({ ...s, [id]: 'testing' }));
|
||||
try {
|
||||
const ping = await pingWithCredentials(data.url, data.username, data.password);
|
||||
const ping = await pingWithCredentialsForProfile(data, data.url);
|
||||
if (ping.ok) {
|
||||
const identity = {
|
||||
type: ping.type,
|
||||
|
||||
@@ -172,6 +172,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Niri compositor tiling WM detection (PR #1127)',
|
||||
'Local library index: Navidrome ignored-articles artist/composer letter buckets (name_sort + server ignoredArticles), idempotent migration with safe open/swap and poisoned-lock recovery (PR #1145)',
|
||||
'All Albums browse: compilation and favorites filters in slice mode — skip redundant client comp filter, route favorites through getStarred2, pre-index page scan, offline isCompilation from tracks (PR #1151)',
|
||||
'Custom HTTP headers for reverse-proxy gates — per-server header editor, TS/Rust registry sync, full playback/sync/cover path coverage, log redaction (PR #1156)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
vi.mock('@/api/subsonic', () => ({
|
||||
pingWithCredentials: vi.fn(),
|
||||
pingWithCredentialsForProfile: vi.fn(),
|
||||
scheduleInstantMixProbeForServer: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -16,7 +17,7 @@ vi.mock('@/utils/perf/perfFlags', () => ({
|
||||
usePerfProbeFlags: () => ({ disableBackgroundPolling: false }),
|
||||
}));
|
||||
|
||||
import { pingWithCredentials } from '@/api/subsonic';
|
||||
import { pingWithCredentialsForProfile } from '@/api/subsonic';
|
||||
import { useDevOfflineBrowseStore } from '@/store/devOfflineBrowseStore';
|
||||
import { useConnectionStatus } from './useConnectionStatus';
|
||||
|
||||
@@ -24,7 +25,7 @@ beforeEach(() => {
|
||||
resetAuthStore();
|
||||
invalidateReachableEndpointCache();
|
||||
useDevOfflineBrowseStore.getState().setForceOffline(false);
|
||||
vi.mocked(pingWithCredentials).mockReset();
|
||||
vi.mocked(pingWithCredentialsForProfile).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -49,7 +50,7 @@ describe('useConnectionStatus.isLan', () => {
|
||||
// LAN endpoint answers — alternateUrl is the LAN side here, so a
|
||||
// primary-url-only check would say "public". We assert it says "local"
|
||||
// (active endpoint kind).
|
||||
vi.mocked(pingWithCredentials).mockImplementation(async url =>
|
||||
vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url) =>
|
||||
url === 'http://192.168.0.10'
|
||||
? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true }
|
||||
: { ok: false },
|
||||
@@ -63,7 +64,7 @@ describe('useConnectionStatus.isLan', () => {
|
||||
it('falls back to public when only the public address answers', async () => {
|
||||
vi.useFakeTimers();
|
||||
seedDualAddressServer();
|
||||
vi.mocked(pingWithCredentials).mockImplementation(async url =>
|
||||
vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url) =>
|
||||
url === 'https://music.example.com'
|
||||
? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true }
|
||||
: { ok: false },
|
||||
@@ -88,7 +89,7 @@ describe('useConnectionStatus.isLan', () => {
|
||||
seedDualAddressServer();
|
||||
// Don't resolve the ping — the hook is still in the `checking` state.
|
||||
let _resolve: ((v: PickReachableResult) => void) | null = null;
|
||||
vi.mocked(pingWithCredentials).mockReturnValue(
|
||||
vi.mocked(pingWithCredentialsForProfile).mockReturnValue(
|
||||
new Promise(r => {
|
||||
_resolve = ((res: PickReachableResult) => {
|
||||
if (res.ok) {
|
||||
@@ -116,7 +117,7 @@ describe('useConnectionStatus online event', () => {
|
||||
it('flushes the reachable-endpoint cache when the browser fires online', async () => {
|
||||
seedDualAddressServer();
|
||||
// Initial probe: LAN answers.
|
||||
vi.mocked(pingWithCredentials).mockImplementation(async url =>
|
||||
vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url) =>
|
||||
url === 'http://192.168.0.10'
|
||||
? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true }
|
||||
: { ok: false },
|
||||
@@ -129,8 +130,8 @@ describe('useConnectionStatus online event', () => {
|
||||
// fire in this test; we trigger the online event instead. The handler
|
||||
// invalidates the sticky cache so the next probe goes LAN-first and
|
||||
// flips over to public when LAN refuses.
|
||||
vi.mocked(pingWithCredentials).mockClear();
|
||||
vi.mocked(pingWithCredentials).mockImplementation(async url =>
|
||||
vi.mocked(pingWithCredentialsForProfile).mockClear();
|
||||
vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url) =>
|
||||
url === 'https://music.example.com'
|
||||
? { ok: true, type: 'navidrome', serverVersion: '0.55.0', openSubsonic: true }
|
||||
: { ok: false },
|
||||
@@ -147,14 +148,14 @@ describe('useConnectionStatus online event', () => {
|
||||
|
||||
await waitFor(() => expect(result.current.isLan).toBe(false));
|
||||
// Both endpoints were probed (LAN refused, public answered).
|
||||
expect(vi.mocked(pingWithCredentials).mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useConnectionStatus DEV offline toggle', () => {
|
||||
it('does not probe again on mount beyond the polling effect', async () => {
|
||||
seedDualAddressServer();
|
||||
vi.mocked(pingWithCredentials).mockResolvedValue({
|
||||
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue({
|
||||
ok: true,
|
||||
type: 'navidrome',
|
||||
serverVersion: '0.55.0',
|
||||
@@ -162,15 +163,15 @@ describe('useConnectionStatus DEV offline toggle', () => {
|
||||
});
|
||||
|
||||
renderHook(() => useConnectionStatus());
|
||||
await waitFor(() => expect(vi.mocked(pingWithCredentials).mock.calls.length).toBeGreaterThanOrEqual(1));
|
||||
const callsAfterMount = vi.mocked(pingWithCredentials).mock.calls.length;
|
||||
await waitFor(() => expect(vi.mocked(pingWithCredentialsForProfile).mock.calls.length).toBeGreaterThanOrEqual(1));
|
||||
const callsAfterMount = vi.mocked(pingWithCredentialsForProfile).mock.calls.length;
|
||||
await new Promise(r => setTimeout(r, 20));
|
||||
expect(vi.mocked(pingWithCredentials).mock.calls.length).toBe(callsAfterMount);
|
||||
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls.length).toBe(callsAfterMount);
|
||||
});
|
||||
|
||||
it('disconnects on force-offline toggle without an extra probe', async () => {
|
||||
seedDualAddressServer();
|
||||
vi.mocked(pingWithCredentials).mockResolvedValue({
|
||||
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue({
|
||||
ok: true,
|
||||
type: 'navidrome',
|
||||
serverVersion: '0.55.0',
|
||||
@@ -179,10 +180,10 @@ describe('useConnectionStatus DEV offline toggle', () => {
|
||||
|
||||
const { result } = renderHook(() => useConnectionStatus());
|
||||
await waitFor(() => expect(result.current.status).toBe('connected'));
|
||||
const callsBeforeToggle = vi.mocked(pingWithCredentials).mock.calls.length;
|
||||
const callsBeforeToggle = vi.mocked(pingWithCredentialsForProfile).mock.calls.length;
|
||||
|
||||
act(() => useDevOfflineBrowseStore.getState().setForceOffline(true));
|
||||
await waitFor(() => expect(result.current.status).toBe('disconnected'));
|
||||
expect(vi.mocked(pingWithCredentials).mock.calls.length).toBe(callsBeforeToggle);
|
||||
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls.length).toBe(callsBeforeToggle);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,24 @@ export const settings = {
|
||||
shareUsesLocalUrlDesc: 'Wirkt sich auf Orbit-Einladungen und Bibliotheks-Freigaben aus. Standard: öffentliche Adresse.',
|
||||
serverUsername: 'Benutzername',
|
||||
serverPassword: 'Passwort',
|
||||
customHeadersTitle: 'Benutzerdefinierte HTTP-Header',
|
||||
customHeadersHelp: 'Für Reverse-Proxy-Gates (Pangolin, Cloudflare Access). Werden nur an deinen Server gesendet — nicht an Drittanbieter.',
|
||||
customHeadersNamePlaceholder: 'Header-Name',
|
||||
customHeadersValuePlaceholder: 'Header-Wert',
|
||||
customHeadersAddRow: 'Header hinzufügen',
|
||||
customHeadersRemoveRow: 'Header entfernen',
|
||||
customHeadersApplyTo: 'Header anwenden auf',
|
||||
customHeadersApplyTo_public: 'Öffentliche Adresse',
|
||||
customHeadersApplyTo_local: 'Lokale Adresse',
|
||||
customHeadersApplyTo_both: 'Beide Adressen',
|
||||
customHeadersNotInShare: 'Benutzerdefinierte Header sind nicht in Freigabe-Einladungen enthalten — auf jedem Gerät erneut konfigurieren.',
|
||||
'customHeadersValidation.tooMany': 'Zu viele benutzerdefinierte Header (max. 16).',
|
||||
'customHeadersValidation.nameRequired': 'Header-Name ist erforderlich.',
|
||||
'customHeadersValidation.nameTooLong': 'Header-Name ist zu lang.',
|
||||
'customHeadersValidation.valueTooLong': 'Header-Wert ist zu lang.',
|
||||
'customHeadersValidation.crlf': 'Header-Namen und -Werte dürfen keine Zeilenumbrüche enthalten.',
|
||||
'customHeadersValidation.blocked': 'Dieser Header-Name ist nicht erlaubt.',
|
||||
'customHeadersValidation.duplicate': 'Doppelter Header-Name.',
|
||||
addServer: 'Server hinzufügen',
|
||||
addServerTitle: 'Neuen Server hinzufügen',
|
||||
editServer: 'Bearbeiten',
|
||||
|
||||
@@ -41,6 +41,24 @@ export const settings = {
|
||||
shareUsesLocalUrlDesc: 'Affects Orbit invites and library shares. Default: public address.',
|
||||
serverUsername: 'Username',
|
||||
serverPassword: 'Password',
|
||||
customHeadersTitle: 'Custom HTTP headers',
|
||||
customHeadersHelp: 'For reverse-proxy gates (Pangolin, Cloudflare Access). Sent only to your server — not to third-party services.',
|
||||
customHeadersNamePlaceholder: 'Header name',
|
||||
customHeadersValuePlaceholder: 'Header value',
|
||||
customHeadersAddRow: 'Add header',
|
||||
customHeadersRemoveRow: 'Remove header',
|
||||
customHeadersApplyTo: 'Apply headers to',
|
||||
customHeadersApplyTo_public: 'Public address',
|
||||
customHeadersApplyTo_local: 'Local address',
|
||||
customHeadersApplyTo_both: 'Both addresses',
|
||||
customHeadersNotInShare: 'Custom headers are not included in share invites — configure them again on each device.',
|
||||
'customHeadersValidation.tooMany': 'Too many custom headers (max 16).',
|
||||
'customHeadersValidation.nameRequired': 'Header name is required.',
|
||||
'customHeadersValidation.nameTooLong': 'Header name is too long.',
|
||||
'customHeadersValidation.valueTooLong': 'Header value is too long.',
|
||||
'customHeadersValidation.crlf': 'Header names and values cannot contain line breaks.',
|
||||
'customHeadersValidation.blocked': 'This header name is not allowed.',
|
||||
'customHeadersValidation.duplicate': 'Duplicate header name.',
|
||||
addServer: 'Add Server',
|
||||
addServerTitle: 'Add New Server',
|
||||
editServer: 'Edit',
|
||||
|
||||
@@ -41,6 +41,24 @@ export const settings = {
|
||||
shareUsesLocalUrlDesc: 'Afecta a las invitaciones de Orbit y a los enlaces de la biblioteca. Por defecto: dirección pública.',
|
||||
serverUsername: 'Usuario',
|
||||
serverPassword: 'Contraseña',
|
||||
customHeadersTitle: 'Encabezados HTTP personalizados',
|
||||
customHeadersHelp: 'Para puertas de reverse proxy (Pangolin, Cloudflare Access). Se envían solo a tu servidor — no a servicios de terceros.',
|
||||
customHeadersNamePlaceholder: 'Nombre del encabezado',
|
||||
customHeadersValuePlaceholder: 'Valor del encabezado',
|
||||
customHeadersAddRow: 'Añadir encabezado',
|
||||
customHeadersRemoveRow: 'Eliminar encabezado',
|
||||
customHeadersApplyTo: 'Aplicar encabezados a',
|
||||
customHeadersApplyTo_public: 'Dirección pública',
|
||||
customHeadersApplyTo_local: 'Dirección local',
|
||||
customHeadersApplyTo_both: 'Ambas direcciones',
|
||||
customHeadersNotInShare: 'Los encabezados personalizados no se incluyen en las invitaciones — configúralos de nuevo en cada dispositivo.',
|
||||
'customHeadersValidation.tooMany': 'Demasiados encabezados personalizados (máx. 16).',
|
||||
'customHeadersValidation.nameRequired': 'El nombre del encabezado es obligatorio.',
|
||||
'customHeadersValidation.nameTooLong': 'El nombre del encabezado es demasiado largo.',
|
||||
'customHeadersValidation.valueTooLong': 'El valor del encabezado es demasiado largo.',
|
||||
'customHeadersValidation.crlf': 'Los nombres y valores no pueden contener saltos de línea.',
|
||||
'customHeadersValidation.blocked': 'Este nombre de encabezado no está permitido.',
|
||||
'customHeadersValidation.duplicate': 'Nombre de encabezado duplicado.',
|
||||
addServer: 'Agregar Servidor',
|
||||
addServerTitle: 'Agregar Nuevo Servidor',
|
||||
editServer: 'Editar',
|
||||
|
||||
@@ -41,6 +41,24 @@ export const settings = {
|
||||
shareUsesLocalUrlDesc: 'Affecte les invitations Orbit et les partages de bibliothèque. Par défaut : adresse publique.',
|
||||
serverUsername: 'Nom d\'utilisateur',
|
||||
serverPassword: 'Mot de passe',
|
||||
customHeadersTitle: 'En-têtes HTTP personnalisés',
|
||||
customHeadersHelp: 'Pour les passerelles reverse proxy (Pangolin, Cloudflare Access). Envoyés uniquement à votre serveur — pas aux services tiers.',
|
||||
customHeadersNamePlaceholder: 'Nom de l’en-tête',
|
||||
customHeadersValuePlaceholder: 'Valeur de l’en-tête',
|
||||
customHeadersAddRow: 'Ajouter un en-tête',
|
||||
customHeadersRemoveRow: 'Supprimer l’en-tête',
|
||||
customHeadersApplyTo: 'Appliquer les en-têtes à',
|
||||
customHeadersApplyTo_public: 'Adresse publique',
|
||||
customHeadersApplyTo_local: 'Adresse locale',
|
||||
customHeadersApplyTo_both: 'Les deux adresses',
|
||||
customHeadersNotInShare: 'Les en-têtes personnalisés ne sont pas inclus dans les invitations — configurez-les à nouveau sur chaque appareil.',
|
||||
'customHeadersValidation.tooMany': 'Trop d’en-têtes personnalisés (max. 16).',
|
||||
'customHeadersValidation.nameRequired': 'Le nom de l’en-tête est requis.',
|
||||
'customHeadersValidation.nameTooLong': 'Le nom de l’en-tête est trop long.',
|
||||
'customHeadersValidation.valueTooLong': 'La valeur de l’en-tête est trop longue.',
|
||||
'customHeadersValidation.crlf': 'Les noms et valeurs ne peuvent pas contenir de saut de ligne.',
|
||||
'customHeadersValidation.blocked': 'Ce nom d’en-tête n’est pas autorisé.',
|
||||
'customHeadersValidation.duplicate': 'Nom d’en-tête en double.',
|
||||
addServer: 'Ajouter un serveur',
|
||||
addServerTitle: 'Ajouter un nouveau serveur',
|
||||
editServer: 'Modifier',
|
||||
|
||||
@@ -41,6 +41,24 @@ export const settings = {
|
||||
shareUsesLocalUrlDesc: 'Érinti az Orbit-meghívókat és a könyvtármegosztásokat. Alapértelmezett: nyilvános cím.',
|
||||
serverUsername: 'Felhasználónév',
|
||||
serverPassword: 'Jelszó',
|
||||
customHeadersTitle: 'Egyéni HTTP fejlécek',
|
||||
customHeadersHelp: 'Reverse-proxy kapukhoz (Pangolin, Cloudflare Access). Csak a szerveredhez kerülnek — harmadik fél szolgáltatásaihoz nem.',
|
||||
customHeadersNamePlaceholder: 'Fejléc neve',
|
||||
customHeadersValuePlaceholder: 'Fejléc értéke',
|
||||
customHeadersAddRow: 'Fejléc hozzáadása',
|
||||
customHeadersRemoveRow: 'Fejléc eltávolítása',
|
||||
customHeadersApplyTo: 'Fejlécek alkalmazása',
|
||||
customHeadersApplyTo_public: 'Nyilvános cím',
|
||||
customHeadersApplyTo_local: 'Helyi cím',
|
||||
customHeadersApplyTo_both: 'Mindkét cím',
|
||||
customHeadersNotInShare: 'Az egyéni fejlécek nem szerepelnek a meghívókban — minden eszközön külön kell beállítani.',
|
||||
'customHeadersValidation.tooMany': 'Túl sok egyéni fejléc (max. 16).',
|
||||
'customHeadersValidation.nameRequired': 'A fejléc neve kötelező.',
|
||||
'customHeadersValidation.nameTooLong': 'A fejléc neve túl hosszú.',
|
||||
'customHeadersValidation.valueTooLong': 'A fejléc értéke túl hosszú.',
|
||||
'customHeadersValidation.crlf': 'A név és az érték nem tartalmazhat sortörést.',
|
||||
'customHeadersValidation.blocked': 'Ez a fejlécnév nem engedélyezett.',
|
||||
'customHeadersValidation.duplicate': 'Ismétlődő fejlécnév.',
|
||||
addServer: 'Szerver hozzáadása',
|
||||
addServerTitle: 'Új szerver hozzáadása',
|
||||
editServer: 'Szerkesztés',
|
||||
|
||||
@@ -41,6 +41,24 @@ export const settings = {
|
||||
shareUsesLocalUrlDesc: 'Orbit 招待とライブラリ共有に影響します。既定: 公開アドレス。',
|
||||
serverUsername: 'ユーザー名',
|
||||
serverPassword: 'パスワード',
|
||||
customHeadersTitle: 'カスタム HTTP ヘッダー',
|
||||
customHeadersHelp: 'リバースプロキシゲート用(Pangolin、Cloudflare Access)。サーバーへの送信のみ — 第三者サービスには送信しません。',
|
||||
customHeadersNamePlaceholder: 'ヘッダー名',
|
||||
customHeadersValuePlaceholder: 'ヘッダー値',
|
||||
customHeadersAddRow: 'ヘッダーを追加',
|
||||
customHeadersRemoveRow: 'ヘッダーを削除',
|
||||
customHeadersApplyTo: 'ヘッダーの適用先',
|
||||
customHeadersApplyTo_public: '公開アドレス',
|
||||
customHeadersApplyTo_local: 'ローカルアドレス',
|
||||
customHeadersApplyTo_both: '両方のアドレス',
|
||||
customHeadersNotInShare: 'カスタムヘッダーは招待リンクに含まれません — 各デバイスで再度設定してください。',
|
||||
'customHeadersValidation.tooMany': 'カスタムヘッダーが多すぎます(最大 16)。',
|
||||
'customHeadersValidation.nameRequired': 'ヘッダー名は必須です。',
|
||||
'customHeadersValidation.nameTooLong': 'ヘッダー名が長すぎます。',
|
||||
'customHeadersValidation.valueTooLong': 'ヘッダー値が長すぎます。',
|
||||
'customHeadersValidation.crlf': 'ヘッダー名と値に改行を含めることはできません。',
|
||||
'customHeadersValidation.blocked': 'このヘッダー名は使用できません。',
|
||||
'customHeadersValidation.duplicate': 'ヘッダー名が重複しています。',
|
||||
addServer: 'サーバーを追加',
|
||||
addServerTitle: '新しいサーバーを追加',
|
||||
editServer: '編集',
|
||||
|
||||
@@ -41,6 +41,24 @@ export const settings = {
|
||||
shareUsesLocalUrlDesc: 'Påvirker Orbit-invitasjoner og biblioteksdeling. Standard: offentlig adresse.',
|
||||
serverUsername: 'Brukernavn',
|
||||
serverPassword: 'Passord',
|
||||
customHeadersTitle: 'Egendefinerte HTTP-headere',
|
||||
customHeadersHelp: 'For reverse-proxy-gates (Pangolin, Cloudflare Access). Sendes bare til serveren din — ikke til tredjepartstjenester.',
|
||||
customHeadersNamePlaceholder: 'Headernavn',
|
||||
customHeadersValuePlaceholder: 'Headerverdi',
|
||||
customHeadersAddRow: 'Legg til header',
|
||||
customHeadersRemoveRow: 'Fjern header',
|
||||
customHeadersApplyTo: 'Bruk headere på',
|
||||
customHeadersApplyTo_public: 'Offentlig adresse',
|
||||
customHeadersApplyTo_local: 'Lokal adresse',
|
||||
customHeadersApplyTo_both: 'Begge adresser',
|
||||
customHeadersNotInShare: 'Egendefinerte headere er ikke inkludert i invitasjoner — konfigurer dem på nytt på hver enhet.',
|
||||
'customHeadersValidation.tooMany': 'For mange egendefinerte headere (maks. 16).',
|
||||
'customHeadersValidation.nameRequired': 'Headernavn er påkrevd.',
|
||||
'customHeadersValidation.nameTooLong': 'Headernavnet er for langt.',
|
||||
'customHeadersValidation.valueTooLong': 'Headerverdien er for lang.',
|
||||
'customHeadersValidation.crlf': 'Headernavn og -verdier kan ikke inneholde linjeskift.',
|
||||
'customHeadersValidation.blocked': 'Dette headernavnet er ikke tillatt.',
|
||||
'customHeadersValidation.duplicate': 'Duplikat headernavn.',
|
||||
addServer: 'Legg til tjener',
|
||||
addServerTitle: 'Legg til ny tjener',
|
||||
editServer: 'Rediger',
|
||||
|
||||
@@ -41,6 +41,24 @@ export const settings = {
|
||||
shareUsesLocalUrlDesc: 'Geldt voor Orbit-uitnodigingen en bibliotheekdelen. Standaard: openbaar adres.',
|
||||
serverUsername: 'Gebruikersnaam',
|
||||
serverPassword: 'Wachtwoord',
|
||||
customHeadersTitle: 'Aangepaste HTTP-headers',
|
||||
customHeadersHelp: 'Voor reverse-proxy-gates (Pangolin, Cloudflare Access). Alleen naar je server gestuurd — niet naar diensten van derden.',
|
||||
customHeadersNamePlaceholder: 'Headernaam',
|
||||
customHeadersValuePlaceholder: 'Headervalue',
|
||||
customHeadersAddRow: 'Header toevoegen',
|
||||
customHeadersRemoveRow: 'Header verwijderen',
|
||||
customHeadersApplyTo: 'Headers toepassen op',
|
||||
customHeadersApplyTo_public: 'Openbaar adres',
|
||||
customHeadersApplyTo_local: 'Lokaal adres',
|
||||
customHeadersApplyTo_both: 'Beide adressen',
|
||||
customHeadersNotInShare: 'Aangepaste headers worden niet opgenomen in uitnodigingen — configureer ze opnieuw op elk apparaat.',
|
||||
'customHeadersValidation.tooMany': 'Te veel aangepaste headers (max. 16).',
|
||||
'customHeadersValidation.nameRequired': 'Headernaam is verplicht.',
|
||||
'customHeadersValidation.nameTooLong': 'Headernaam is te lang.',
|
||||
'customHeadersValidation.valueTooLong': 'Headervalue is te lang.',
|
||||
'customHeadersValidation.crlf': 'Headernamen en -waarden mogen geen regeleinden bevatten.',
|
||||
'customHeadersValidation.blocked': 'Deze headernaam is niet toegestaan.',
|
||||
'customHeadersValidation.duplicate': 'Dubbele headernaam.',
|
||||
addServer: 'Server toevoegen',
|
||||
addServerTitle: 'Nieuwe server toevoegen',
|
||||
editServer: 'Bewerken',
|
||||
|
||||
@@ -41,6 +41,24 @@ export const settings = {
|
||||
shareUsesLocalUrlDesc: 'Afectează invitațiile Orbit și partajările bibliotecii. Implicit: adresa publică.',
|
||||
serverUsername: 'Nume utilizator',
|
||||
serverPassword: 'Parolă',
|
||||
customHeadersTitle: 'Antete HTTP personalizate',
|
||||
customHeadersHelp: 'Pentru porți reverse proxy (Pangolin, Cloudflare Access). Trimise doar către serverul tău — nu către servicii terțe.',
|
||||
customHeadersNamePlaceholder: 'Nume antet',
|
||||
customHeadersValuePlaceholder: 'Valoare antet',
|
||||
customHeadersAddRow: 'Adaugă antet',
|
||||
customHeadersRemoveRow: 'Elimină antet',
|
||||
customHeadersApplyTo: 'Aplică antetele la',
|
||||
customHeadersApplyTo_public: 'Adresă publică',
|
||||
customHeadersApplyTo_local: 'Adresă locală',
|
||||
customHeadersApplyTo_both: 'Ambele adrese',
|
||||
customHeadersNotInShare: 'Antetele personalizate nu sunt incluse în invitații — configurează-le din nou pe fiecare dispozitiv.',
|
||||
'customHeadersValidation.tooMany': 'Prea multe antete personalizate (max. 16).',
|
||||
'customHeadersValidation.nameRequired': 'Numele antetului este obligatoriu.',
|
||||
'customHeadersValidation.nameTooLong': 'Numele antetului este prea lung.',
|
||||
'customHeadersValidation.valueTooLong': 'Valoarea antetului este prea lungă.',
|
||||
'customHeadersValidation.crlf': 'Numele și valorile nu pot conține linii noi.',
|
||||
'customHeadersValidation.blocked': 'Acest nume de antet nu este permis.',
|
||||
'customHeadersValidation.duplicate': 'Nume de antet duplicat.',
|
||||
addServer: 'Adaugă Server',
|
||||
addServerTitle: 'Adaugă Server nou',
|
||||
editServer: 'Editează',
|
||||
|
||||
@@ -41,6 +41,24 @@ export const settings = {
|
||||
shareUsesLocalUrlDesc: 'Применяется к приглашениям Orbit и ссылкам на библиотеку. По умолчанию — публичный адрес.',
|
||||
serverUsername: 'Логин',
|
||||
serverPassword: 'Пароль',
|
||||
customHeadersTitle: 'Пользовательские HTTP-заголовки',
|
||||
customHeadersHelp: 'Для reverse-proxy (Pangolin, Cloudflare Access). Отправляются только на ваш сервер — не сторонним сервисам.',
|
||||
customHeadersNamePlaceholder: 'Имя заголовка',
|
||||
customHeadersValuePlaceholder: 'Значение заголовка',
|
||||
customHeadersAddRow: 'Добавить заголовок',
|
||||
customHeadersRemoveRow: 'Удалить заголовок',
|
||||
customHeadersApplyTo: 'Применять заголовки к',
|
||||
customHeadersApplyTo_public: 'Публичному адресу',
|
||||
customHeadersApplyTo_local: 'Локальному адресу',
|
||||
customHeadersApplyTo_both: 'Оба адреса',
|
||||
customHeadersNotInShare: 'Заголовки не входят в приглашения — настройте их на каждом устройстве отдельно.',
|
||||
'customHeadersValidation.tooMany': 'Слишком много заголовков (макс. 16).',
|
||||
'customHeadersValidation.nameRequired': 'Укажите имя заголовка.',
|
||||
'customHeadersValidation.nameTooLong': 'Имя заголовка слишком длинное.',
|
||||
'customHeadersValidation.valueTooLong': 'Значение заголовка слишком длинное.',
|
||||
'customHeadersValidation.crlf': 'Имя и значение не могут содержать переводы строк.',
|
||||
'customHeadersValidation.blocked': 'Это имя заголовка запрещено.',
|
||||
'customHeadersValidation.duplicate': 'Дублирующееся имя заголовка.',
|
||||
addServer: 'Добавить сервер',
|
||||
addServerTitle: 'Новый сервер',
|
||||
editServer: 'Изменить',
|
||||
|
||||
@@ -41,6 +41,24 @@ export const settings = {
|
||||
shareUsesLocalUrlDesc: '影响 Orbit 邀请和资料库分享。默认:公共地址。',
|
||||
serverUsername: '用户名',
|
||||
serverPassword: '密码',
|
||||
customHeadersTitle: '自定义 HTTP 标头',
|
||||
customHeadersHelp: '用于反向代理网关(Pangolin、Cloudflare Access)。仅发送到你的服务器 — 不会发送给第三方服务。',
|
||||
customHeadersNamePlaceholder: '标头名称',
|
||||
customHeadersValuePlaceholder: '标头值',
|
||||
customHeadersAddRow: '添加标头',
|
||||
customHeadersRemoveRow: '删除标头',
|
||||
customHeadersApplyTo: '将标头应用于',
|
||||
customHeadersApplyTo_public: '公共地址',
|
||||
customHeadersApplyTo_local: '本地地址',
|
||||
customHeadersApplyTo_both: '两个地址',
|
||||
customHeadersNotInShare: '自定义标头不会包含在分享邀请中 — 请在每台设备上重新配置。',
|
||||
'customHeadersValidation.tooMany': '自定义标头过多(最多 16 个)。',
|
||||
'customHeadersValidation.nameRequired': '标头名称不能为空。',
|
||||
'customHeadersValidation.nameTooLong': '标头名称过长。',
|
||||
'customHeadersValidation.valueTooLong': '标头值过长。',
|
||||
'customHeadersValidation.crlf': '标头名称和值不能包含换行符。',
|
||||
'customHeadersValidation.blocked': '不允许使用此标头名称。',
|
||||
'customHeadersValidation.duplicate': '标头名称重复。',
|
||||
addServer: '添加服务器',
|
||||
addServerTitle: '添加新服务器',
|
||||
editServer: '编辑',
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DEFAULT_LIBRARY_GRID_MAX_COLUMNS,
|
||||
} from './authStoreDefaults';
|
||||
import { computeAuthStoreRehydration } from './authStoreRehydrate';
|
||||
import { syncAllServerHttpContexts } from '../utils/server/syncServerHttpContext';
|
||||
import type { AuthState } from './authStoreTypes';
|
||||
import { getCachedConnectBaseUrl } from '../utils/server/serverEndpoint';
|
||||
import { serverProfileBaseUrl } from '../utils/server/serverBaseUrl';
|
||||
@@ -172,6 +173,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
onRehydrateStorage: () => (state, error) => {
|
||||
if (error || !state) return;
|
||||
useAuthStore.setState(computeAuthStoreRehydration(state));
|
||||
void syncAllServerHttpContexts(useAuthStore.getState().servers);
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -6,6 +6,23 @@ import type {
|
||||
} from '../utils/server/subsonicServerIdentity';
|
||||
import type { PersistedAccount } from '../music-network';
|
||||
|
||||
export type CustomHeaderEntry = {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type CustomHeadersApplyTo = 'local' | 'public' | 'both';
|
||||
|
||||
export type CustomHeadersFieldError = {
|
||||
index: number;
|
||||
field: 'name' | 'value';
|
||||
messageKey: string;
|
||||
};
|
||||
|
||||
export type CustomHeadersValidationResult =
|
||||
| { ok: true }
|
||||
| { ok: false; fieldErrors: CustomHeadersFieldError[]; formMessage?: string };
|
||||
|
||||
export interface ServerProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -30,6 +47,10 @@ export interface ServerProfile {
|
||||
shareUsesLocalUrl?: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
/** Optional HTTP headers for reverse-proxy gates (Pangolin, Cloudflare Access). */
|
||||
customHeaders?: CustomHeaderEntry[];
|
||||
/** Which profile endpoint(s) receive `customHeaders`. Default when absent: `'public'`. */
|
||||
customHeadersApplyTo?: CustomHeadersApplyTo;
|
||||
}
|
||||
|
||||
export type SeekbarStyle = 'truewave' | 'pseudowave' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape';
|
||||
|
||||
@@ -23,6 +23,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
// Listing every export the store uses keeps the override stable.
|
||||
vi.mock('@/api/subsonic', () => ({
|
||||
pingWithCredentials: vi.fn(async () => ({ ok: true })),
|
||||
pingWithCredentialsForProfile: vi.fn(async () => ({ ok: true })),
|
||||
scheduleInstantMixProbeForServer: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/api/subsonicPlayQueue', () => ({
|
||||
savePlayQueue: vi.fn(async () => undefined),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useAuthStore } from '../../store/authStore';
|
||||
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
||||
import { ensureConnectUrlResolved } from '../server/serverEndpoint';
|
||||
import { serverIndexKeyForProfile } from '../server/serverIndexKey';
|
||||
import { syncServerHttpContextForProfile } from '../server/syncServerHttpContext';
|
||||
import { libraryDevEnabled, logLibraryStatus, logLibrarySync, timed } from './libraryDevLog';
|
||||
|
||||
export type BindServerResult = 'bound' | 'offline' | 'error';
|
||||
@@ -49,6 +50,7 @@ export async function bindIndexedServer(server: ServerProfile): Promise<BindServ
|
||||
});
|
||||
logLibraryStatus(server.id, status, 'bind_session');
|
||||
}
|
||||
void syncServerHttpContextForProfile(server);
|
||||
return 'bound';
|
||||
} catch {
|
||||
return 'error';
|
||||
|
||||
@@ -34,4 +34,15 @@ describe('sanitizeLogLine', () => {
|
||||
expect(out).toContain('password=REDACTED');
|
||||
expect(out).not.toContain('sekrit');
|
||||
});
|
||||
|
||||
it('redacts reverse-proxy gate header values', () => {
|
||||
const line = 'req CF-Access-Client-Secret: gate-secret Authorization: Bearer tok123 x-pangolin-auth: pangolin-key';
|
||||
const out = sanitizeLogLine(line);
|
||||
expect(out).toContain('CF-Access-Client-Secret: REDACTED');
|
||||
expect(out).not.toContain('gate-secret');
|
||||
expect(out).not.toContain('tok123');
|
||||
expect(out).toContain('x-pangolin-auth: REDACTED');
|
||||
expect(out).not.toContain('pangolin-key');
|
||||
expect(out).not.toMatch(/Authorization:\s*Bearer\s+\S/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,8 +11,12 @@ const SENSITIVE_QUERY_KEYS = new Set([
|
||||
const SENSITIVE_KV_KEYS = [
|
||||
'password', 'passwd', 'token', 'secret', 'api_key', 'apikey',
|
||||
'access_token', 'refresh_token', 'authorization', 'auth',
|
||||
'cookie', 'x-api-key', 'cf-access-client-secret', 'cf-access-client-id', 'x-auth-token',
|
||||
];
|
||||
|
||||
/** Gate / reverse-proxy header names — redact any `x-pangolin-*` prefix. */
|
||||
const PANGOLIN_HEADER_RE = /(\bx-pangolin-[a-z0-9-]+\s*[:=]\s*)(\S+)/gi;
|
||||
|
||||
function isIpv4LanLiteral(ip: string): boolean {
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) return false;
|
||||
@@ -175,6 +179,10 @@ function redactBearerTokens(line: string): string {
|
||||
return s;
|
||||
}
|
||||
|
||||
function redactPangolinHeaders(line: string): string {
|
||||
return line.replace(PANGOLIN_HEADER_RE, '$1REDACTED');
|
||||
}
|
||||
|
||||
function redactSensitiveKeyValues(line: string): string {
|
||||
let out = line;
|
||||
for (const key of SENSITIVE_KV_KEYS) {
|
||||
@@ -231,5 +239,5 @@ function redactUrlsInText(line: string): string {
|
||||
}
|
||||
|
||||
export function sanitizeLogLine(line: string): string {
|
||||
return redactUrlsInText(redactSensitiveKeyValues(redactBearerTokens(line)));
|
||||
return redactUrlsInText(redactSensitiveKeyValues(redactPangolinHeaders(redactBearerTokens(line))));
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../api/subsonic', () => ({
|
||||
pingWithCredentials: vi.fn(),
|
||||
pingWithCredentialsForProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
import { pingWithCredentials } from '../../api/subsonic';
|
||||
import { pingWithCredentialsForProfile } from '../../api/subsonic';
|
||||
import {
|
||||
allNormalizedAddresses,
|
||||
ensureConnectUrlResolved,
|
||||
@@ -47,7 +48,7 @@ function pingFail() {
|
||||
const CONNECT_PROBE_ATTEMPTS = 3;
|
||||
|
||||
function mockDualAddressLanFailPublicOk() {
|
||||
vi.mocked(pingWithCredentials).mockImplementation(async (url: string) => {
|
||||
vi.mocked(pingWithCredentialsForProfile).mockImplementation(async (_profile, url: string) => {
|
||||
if (url === 'http://192.168.0.10') return pingFail();
|
||||
return pingOk();
|
||||
});
|
||||
@@ -212,7 +213,7 @@ describe('serverAddressEndpoints', () => {
|
||||
describe('pickReachableBaseUrl', () => {
|
||||
beforeEach(() => {
|
||||
invalidateReachableEndpointCache();
|
||||
vi.mocked(pingWithCredentials).mockReset();
|
||||
vi.mocked(pingWithCredentialsForProfile).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -220,7 +221,7 @@ describe('pickReachableBaseUrl', () => {
|
||||
});
|
||||
|
||||
it('returns the single endpoint when it pings ok and caches it', async () => {
|
||||
vi.mocked(pingWithCredentials).mockResolvedValue(pingOk());
|
||||
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingOk());
|
||||
const result = await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
@@ -230,11 +231,11 @@ describe('pickReachableBaseUrl', () => {
|
||||
expect(result.ping.type).toBe('navidrome');
|
||||
}
|
||||
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
|
||||
expect(pingWithCredentials).toHaveBeenCalledTimes(1);
|
||||
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prefers the LAN endpoint even when alternateUrl is the LAN one', async () => {
|
||||
vi.mocked(pingWithCredentials).mockResolvedValue(pingOk());
|
||||
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingOk());
|
||||
const result = await pickReachableBaseUrl(
|
||||
makeProfile({
|
||||
url: 'https://music.example.com',
|
||||
@@ -243,8 +244,8 @@ describe('pickReachableBaseUrl', () => {
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.baseUrl).toBe('http://192.168.0.10');
|
||||
expect(pingWithCredentials).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(pingWithCredentials).mock.calls[0]![0]).toBe('http://192.168.0.10');
|
||||
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls[0]![1]).toBe('http://192.168.0.10');
|
||||
});
|
||||
|
||||
it('falls through to the public endpoint when LAN ping fails', async () => {
|
||||
@@ -260,13 +261,13 @@ describe('pickReachableBaseUrl', () => {
|
||||
const result = await promise;
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.baseUrl).toBe('https://music.example.com');
|
||||
expect(pingWithCredentials).toHaveBeenCalledTimes(CONNECT_PROBE_ATTEMPTS + 1);
|
||||
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(CONNECT_PROBE_ATTEMPTS + 1);
|
||||
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
|
||||
});
|
||||
|
||||
it('retries a flaky endpoint before declaring it unreachable', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(pingWithCredentials)
|
||||
vi.mocked(pingWithCredentialsForProfile)
|
||||
.mockResolvedValueOnce(pingFail())
|
||||
.mockResolvedValueOnce(pingOk());
|
||||
const promise = pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
|
||||
@@ -274,32 +275,32 @@ describe('pickReachableBaseUrl', () => {
|
||||
const result = await promise;
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.baseUrl).toBe('https://music.example.com');
|
||||
expect(pingWithCredentials).toHaveBeenCalledTimes(2);
|
||||
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(2);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns unreachable and clears cache when every endpoint fails', async () => {
|
||||
vi.mocked(pingWithCredentials).mockResolvedValue(pingFail());
|
||||
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingFail());
|
||||
// Seed a stale cache entry first.
|
||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
||||
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||
await pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
|
||||
expect(getCachedConnectBaseUrl('profile-1')).toBe('https://music.example.com');
|
||||
|
||||
vi.mocked(pingWithCredentials).mockReset();
|
||||
vi.mocked(pingWithCredentials).mockResolvedValue(pingFail());
|
||||
vi.mocked(pingWithCredentialsForProfile).mockReset();
|
||||
vi.mocked(pingWithCredentialsForProfile).mockResolvedValue(pingFail());
|
||||
vi.useFakeTimers();
|
||||
const unreachablePromise = pickReachableBaseUrl(makeProfile({ url: 'https://music.example.com' }));
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await unreachablePromise;
|
||||
expect(result).toEqual({ ok: false, reason: 'unreachable' });
|
||||
expect(getCachedConnectBaseUrl('profile-1')).toBeNull();
|
||||
expect(pingWithCredentials).toHaveBeenCalledTimes(CONNECT_PROBE_ATTEMPTS);
|
||||
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(CONNECT_PROBE_ATTEMPTS);
|
||||
});
|
||||
|
||||
it('returns unreachable when the profile has no usable url', async () => {
|
||||
const result = await pickReachableBaseUrl(makeProfile({ url: '' }));
|
||||
expect(result).toEqual({ ok: false, reason: 'unreachable' });
|
||||
expect(pingWithCredentials).not.toHaveBeenCalled();
|
||||
expect(pingWithCredentialsForProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tries the cached endpoint first on subsequent calls (sticky)', async () => {
|
||||
@@ -308,18 +309,18 @@ describe('pickReachableBaseUrl', () => {
|
||||
alternateUrl: 'http://192.168.0.10',
|
||||
});
|
||||
// First call: LAN responds ok, becomes cached.
|
||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
||||
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||
await pickReachableBaseUrl(profile);
|
||||
expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10');
|
||||
|
||||
// Second call: cached URL is tried first; sole ping happens against it.
|
||||
vi.mocked(pingWithCredentials).mockClear();
|
||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
||||
vi.mocked(pingWithCredentialsForProfile).mockClear();
|
||||
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||
const result = await pickReachableBaseUrl(profile);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.baseUrl).toBe('http://192.168.0.10');
|
||||
expect(pingWithCredentials).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(pingWithCredentials).mock.calls[0]![0]).toBe('http://192.168.0.10');
|
||||
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(pingWithCredentialsForProfile).mock.calls[0]![1]).toBe('http://192.168.0.10');
|
||||
});
|
||||
|
||||
it('dedupes concurrent calls for the same profile (single shared probe)', async () => {
|
||||
@@ -328,7 +329,7 @@ describe('pickReachableBaseUrl', () => {
|
||||
// cache write, with last-write-wins potentially clobbering the correct
|
||||
// LAN sticky a millisecond after it was set.
|
||||
let resolvePing: ((v: ReturnType<typeof pingOk>) => void) | null = null;
|
||||
vi.mocked(pingWithCredentials).mockReturnValueOnce(
|
||||
vi.mocked(pingWithCredentialsForProfile).mockReturnValueOnce(
|
||||
new Promise(r => {
|
||||
resolvePing = r;
|
||||
}),
|
||||
@@ -338,7 +339,7 @@ describe('pickReachableBaseUrl', () => {
|
||||
const p2 = pickReachableBaseUrl(profile);
|
||||
|
||||
// Both calls saw a pending probe — only one ping should have been fired.
|
||||
expect(pingWithCredentials).toHaveBeenCalledTimes(1);
|
||||
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolvePing!(pingOk());
|
||||
const [r1, r2] = await Promise.all([p1, p2]);
|
||||
@@ -354,13 +355,13 @@ describe('pickReachableBaseUrl', () => {
|
||||
it('starts a fresh probe after the in-flight one settles', async () => {
|
||||
// Once the previous probe resolves, the in-flight slot is freed and
|
||||
// the next call hits the network again (subject to the sticky cache).
|
||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
||||
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||
await pickReachableBaseUrl(makeProfile({ url: 'http://192.168.0.10' }));
|
||||
|
||||
vi.mocked(pingWithCredentials).mockClear();
|
||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
||||
vi.mocked(pingWithCredentialsForProfile).mockClear();
|
||||
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||
await pickReachableBaseUrl(makeProfile({ url: 'http://192.168.0.10' }));
|
||||
expect(pingWithCredentials).toHaveBeenCalledTimes(1);
|
||||
expect(pingWithCredentialsForProfile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to the natural order if the cached endpoint stops answering', async () => {
|
||||
@@ -368,12 +369,12 @@ describe('pickReachableBaseUrl', () => {
|
||||
url: 'https://music.example.com',
|
||||
alternateUrl: 'http://192.168.0.10',
|
||||
});
|
||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
||||
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||
await pickReachableBaseUrl(profile);
|
||||
expect(getCachedConnectBaseUrl('profile-1')).toBe('http://192.168.0.10');
|
||||
|
||||
// LAN now fails; public answers.
|
||||
vi.mocked(pingWithCredentials).mockClear();
|
||||
vi.mocked(pingWithCredentialsForProfile).mockClear();
|
||||
vi.useFakeTimers();
|
||||
mockDualAddressLanFailPublicOk();
|
||||
const fallbackPromise = pickReachableBaseUrl(profile);
|
||||
@@ -388,13 +389,13 @@ describe('pickReachableBaseUrl', () => {
|
||||
describe('invalidateReachableEndpointCache', () => {
|
||||
beforeEach(() => {
|
||||
invalidateReachableEndpointCache();
|
||||
vi.mocked(pingWithCredentials).mockReset();
|
||||
vi.mocked(pingWithCredentialsForProfile).mockReset();
|
||||
});
|
||||
|
||||
it('clears a specific profile', async () => {
|
||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
||||
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||
await ensureConnectUrlResolved(makeProfile({ id: 'a' }));
|
||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
||||
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||
await ensureConnectUrlResolved(makeProfile({ id: 'b' }));
|
||||
expect(getCachedConnectBaseUrl('a')).not.toBeNull();
|
||||
expect(getCachedConnectBaseUrl('b')).not.toBeNull();
|
||||
@@ -405,7 +406,7 @@ describe('invalidateReachableEndpointCache', () => {
|
||||
});
|
||||
|
||||
it('clears everything when called with no argument', async () => {
|
||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
||||
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||
await ensureConnectUrlResolved(makeProfile({ id: 'a' }));
|
||||
invalidateReachableEndpointCache();
|
||||
expect(getCachedConnectBaseUrl('a')).toBeNull();
|
||||
@@ -415,7 +416,7 @@ describe('invalidateReachableEndpointCache', () => {
|
||||
describe('subscribeConnectCache — connect-URL flip notifications', () => {
|
||||
beforeEach(() => {
|
||||
invalidateReachableEndpointCache();
|
||||
vi.mocked(pingWithCredentials).mockReset();
|
||||
vi.mocked(pingWithCredentialsForProfile).mockReset();
|
||||
});
|
||||
|
||||
it('notifies when a probe resolves a new endpoint and on a later flip', async () => {
|
||||
@@ -427,7 +428,7 @@ describe('subscribeConnectCache — connect-URL flip notifications', () => {
|
||||
});
|
||||
|
||||
// First probe: LAN answers → cache set → one notification.
|
||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
||||
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||
await pickReachableBaseUrl(profile);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -444,13 +445,13 @@ describe('subscribeConnectCache — connect-URL flip notifications', () => {
|
||||
|
||||
it('does not notify when the sticky endpoint is unchanged', async () => {
|
||||
const profile = makeProfile({ url: 'http://192.168.0.10' });
|
||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
||||
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||
await pickReachableBaseUrl(profile);
|
||||
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribeConnectCache(listener);
|
||||
// Re-probe, same endpoint answers → cache value identical → no notification.
|
||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
||||
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||
await pickReachableBaseUrl(profile);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
@@ -458,7 +459,7 @@ describe('subscribeConnectCache — connect-URL flip notifications', () => {
|
||||
});
|
||||
|
||||
it('notifies on explicit cache invalidation when an entry existed', async () => {
|
||||
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
|
||||
vi.mocked(pingWithCredentialsForProfile).mockResolvedValueOnce(pingOk());
|
||||
await pickReachableBaseUrl(makeProfile({ id: 'a' }));
|
||||
|
||||
const listener = vi.fn();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { pingWithCredentials } from '../../api/subsonic';
|
||||
import { pingWithCredentialsForProfile } from '../../api/subsonic';
|
||||
import type { PingWithCredentialsResult } from '../../api/subsonicTypes';
|
||||
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||
import { serverProfileBaseUrl } from './serverBaseUrl';
|
||||
@@ -261,15 +261,14 @@ function sleepMs(ms: number): Promise<void> {
|
||||
* proxy TLS flakes) before the connection indicator marks the server down.
|
||||
*/
|
||||
async function pingWithConnectRetries(
|
||||
baseUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
profile: ServerProfile,
|
||||
endpointUrl: string,
|
||||
): Promise<PingWithCredentialsResult> {
|
||||
let ping = await pingWithCredentials(baseUrl, username, password);
|
||||
let ping = await pingWithCredentialsForProfile(profile, endpointUrl);
|
||||
if (ping.ok) return ping;
|
||||
for (let retry = 0; retry < CONNECT_PING_RETRIES; retry++) {
|
||||
await sleepMs(CONNECT_PING_RETRY_DELAY_MS);
|
||||
ping = await pingWithCredentials(baseUrl, username, password);
|
||||
ping = await pingWithCredentialsForProfile(profile, endpointUrl);
|
||||
if (ping.ok) return ping;
|
||||
}
|
||||
return ping;
|
||||
@@ -309,7 +308,7 @@ export async function pickReachableBaseUrl(
|
||||
: ordered;
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
const ping = await pingWithConnectRetries(endpoint.url, profile.username, profile.password);
|
||||
const ping = await pingWithConnectRetries(profile, endpoint.url);
|
||||
if (ping.ok) {
|
||||
const prev = connectCache.get(profile.id);
|
||||
connectCache.set(profile.id, endpoint.url);
|
||||
|
||||
@@ -15,9 +15,16 @@
|
||||
*/
|
||||
|
||||
import md5 from 'md5';
|
||||
import { apiWithCredentials, restBaseFromUrl, secureRandomSalt, SUBSONIC_CLIENT } from '../../api/subsonicClient';
|
||||
import {
|
||||
apiWithCredentials,
|
||||
restBaseFromUrl,
|
||||
secureRandomSalt,
|
||||
SUBSONIC_CLIENT,
|
||||
type ServerHttpHeaderProfile,
|
||||
} from '../../api/subsonicClient';
|
||||
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||
import { allNormalizedAddresses } from './serverEndpoint';
|
||||
import { headersForServerRequest } from './serverHttpHeaders';
|
||||
|
||||
export type ServerFingerprint = {
|
||||
ping: {
|
||||
@@ -55,6 +62,7 @@ async function fetchPingFingerprint(
|
||||
baseUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
headerProfile?: ServerHttpHeaderProfile,
|
||||
): Promise<PingFingerprint> {
|
||||
// Mirrors pingWithCredentials but also extracts envelope `version`.
|
||||
// Using fetch (the bundled axios pulls in extra noise here; one call is fine).
|
||||
@@ -69,9 +77,18 @@ async function fetchPingFingerprint(
|
||||
f: 'json',
|
||||
});
|
||||
const url = `${restBaseFromUrl(baseUrl)}/ping.view?${params.toString()}`;
|
||||
const profileForHeaders: ServerHttpHeaderProfile = headerProfile ?? {
|
||||
url: baseUrl,
|
||||
alternateUrl: undefined,
|
||||
customHeaders: undefined,
|
||||
customHeadersApplyTo: undefined,
|
||||
};
|
||||
let body: Record<string, unknown> | null = null;
|
||||
try {
|
||||
const resp = await fetch(url, { method: 'GET' });
|
||||
const resp = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: headersForServerRequest(profileForHeaders, baseUrl),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const json = (await resp.json()) as Record<string, unknown>;
|
||||
body = (json?.['subsonic-response'] as Record<string, unknown>) ?? null;
|
||||
@@ -171,12 +188,13 @@ export async function fetchServerFingerprint(
|
||||
baseUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
headerProfile?: ServerHttpHeaderProfile,
|
||||
): Promise<ServerFingerprint> {
|
||||
// Ping is required — but we still build a (mostly-null) fingerprint when
|
||||
// it fails so callers can tell the difference between "server unreachable"
|
||||
// (verify reports `unreachable`) and "server reachable but disagrees"
|
||||
// (verify reports `mismatch` / `insufficient`).
|
||||
const ping = await fetchPingFingerprint(baseUrl, username, password);
|
||||
const ping = await fetchPingFingerprint(baseUrl, username, password, headerProfile);
|
||||
|
||||
// The optional calls only make sense once ping succeeded — without that,
|
||||
// any subsequent call against the same URL is just wasted bandwidth.
|
||||
@@ -196,10 +214,42 @@ export async function fetchServerFingerprint(
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled([
|
||||
apiWithCredentials<Record<string, unknown>>(baseUrl, username, password, 'getMusicFolders.view'),
|
||||
apiWithCredentials<Record<string, unknown>>(baseUrl, username, password, 'getUser.view', { username }),
|
||||
apiWithCredentials<Record<string, unknown>>(baseUrl, username, password, 'getLicense.view'),
|
||||
apiWithCredentials<Record<string, unknown>>(baseUrl, username, password, 'getIndexes.view'),
|
||||
apiWithCredentials<Record<string, unknown>>(
|
||||
baseUrl,
|
||||
username,
|
||||
password,
|
||||
'getMusicFolders.view',
|
||||
{},
|
||||
15000,
|
||||
headerProfile,
|
||||
),
|
||||
apiWithCredentials<Record<string, unknown>>(
|
||||
baseUrl,
|
||||
username,
|
||||
password,
|
||||
'getUser.view',
|
||||
{ username },
|
||||
15000,
|
||||
headerProfile,
|
||||
),
|
||||
apiWithCredentials<Record<string, unknown>>(
|
||||
baseUrl,
|
||||
username,
|
||||
password,
|
||||
'getLicense.view',
|
||||
{},
|
||||
15000,
|
||||
headerProfile,
|
||||
),
|
||||
apiWithCredentials<Record<string, unknown>>(
|
||||
baseUrl,
|
||||
username,
|
||||
password,
|
||||
'getIndexes.view',
|
||||
{},
|
||||
15000,
|
||||
headerProfile,
|
||||
),
|
||||
]);
|
||||
|
||||
const [foldersResult, userResult, licenseResult, indexesResult] = settled;
|
||||
@@ -299,7 +349,10 @@ function musicFoldersEqual(
|
||||
* `ok: true` — nothing to verify.
|
||||
*/
|
||||
export async function verifySameServerEndpoints(
|
||||
profile: Pick<ServerProfile, 'url' | 'alternateUrl'>,
|
||||
profile: Pick<
|
||||
ServerProfile,
|
||||
'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo'
|
||||
>,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<VerifySameServerResult> {
|
||||
@@ -307,7 +360,7 @@ export async function verifySameServerEndpoints(
|
||||
if (endpoints.length <= 1) return { ok: true };
|
||||
|
||||
const fingerprints = await Promise.all(
|
||||
endpoints.map(baseUrl => fetchServerFingerprint(baseUrl, username, password)),
|
||||
endpoints.map(baseUrl => fetchServerFingerprint(baseUrl, username, password, profile)),
|
||||
);
|
||||
|
||||
// If any ping failed → unreachable (with the offending host for the UI).
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
headersForServerRequest,
|
||||
requestBaseUrlFromHttpUrl,
|
||||
validateCustomHeaders,
|
||||
} from './serverHttpHeaders';
|
||||
|
||||
describe('requestBaseUrlFromHttpUrl', () => {
|
||||
it('strips /rest/ path and query from stream URLs', () => {
|
||||
expect(
|
||||
requestBaseUrlFromHttpUrl(
|
||||
'https://music.example.com/rest/stream.view?id=1&u=x&t=y&s=z',
|
||||
),
|
||||
).toBe('https://music.example.com');
|
||||
});
|
||||
|
||||
it('strips /api/ Navidrome paths', () => {
|
||||
expect(requestBaseUrlFromHttpUrl('https://nd.local/api/album')).toBe('https://nd.local');
|
||||
});
|
||||
});
|
||||
|
||||
describe('headersForServerRequest', () => {
|
||||
const profile = {
|
||||
url: 'https://music.example.com',
|
||||
alternateUrl: 'http://192.168.1.10:4533',
|
||||
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'secret' }],
|
||||
customHeadersApplyTo: 'public' as const,
|
||||
};
|
||||
|
||||
it('applies headers on public endpoint only when applyTo is public', () => {
|
||||
expect(headersForServerRequest(profile, 'https://music.example.com')).toEqual({
|
||||
'CF-Access-Client-Secret': 'secret',
|
||||
});
|
||||
expect(headersForServerRequest(profile, 'http://192.168.1.10:4533')).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty for foreign base URL', () => {
|
||||
expect(headersForServerRequest(profile, 'https://other.example.com')).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCustomHeaders', () => {
|
||||
it('rejects blocked header names', () => {
|
||||
const result = validateCustomHeaders([{ name: 'Host', value: 'x' }]);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts valid rows', () => {
|
||||
expect(
|
||||
validateCustomHeaders([{ name: 'X-Custom', value: 'ok' }]),
|
||||
).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import type {
|
||||
CustomHeaderEntry,
|
||||
CustomHeadersApplyTo,
|
||||
CustomHeadersFieldError,
|
||||
CustomHeadersValidationResult,
|
||||
ServerProfile,
|
||||
} from '../../store/authStoreTypes';
|
||||
import { serverIndexKeyForProfile } from './serverIndexKey';
|
||||
import { normalizeServerBaseUrl, serverAddressEndpoints, type ServerEndpointKind } from './serverEndpoint';
|
||||
|
||||
export const DEFAULT_CUSTOM_HEADERS_APPLY_TO: CustomHeadersApplyTo = 'public';
|
||||
|
||||
export const CUSTOM_HEADER_NAME_BLOCKLIST = new Set([
|
||||
'host',
|
||||
'content-length',
|
||||
'transfer-encoding',
|
||||
'connection',
|
||||
'cookie',
|
||||
]);
|
||||
|
||||
const MAX_CUSTOM_HEADERS = 16;
|
||||
const MAX_HEADER_NAME_LEN = 256;
|
||||
const MAX_HEADER_VALUE_LEN = 8192;
|
||||
|
||||
export function normalizeHeaderName(name: string): string {
|
||||
return name.trim();
|
||||
}
|
||||
|
||||
export function requestBaseUrlFromHttpUrl(rawUrl: string): string {
|
||||
const trimmed = rawUrl.trim();
|
||||
if (!trimmed) return '';
|
||||
try {
|
||||
const parsed = new URL(trimmed.startsWith('http') ? trimmed : `http://${trimmed}`);
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
let path = parsed.pathname;
|
||||
const restIdx = path.indexOf('/rest/');
|
||||
if (restIdx >= 0 || path.endsWith('/rest')) {
|
||||
path = path.slice(0, restIdx >= 0 ? restIdx : path.length - '/rest'.length);
|
||||
} else {
|
||||
for (const seg of ['/api/', '/auth/'] as const) {
|
||||
const idx = path.indexOf(seg);
|
||||
if (idx >= 0) {
|
||||
path = path.slice(0, idx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (path.endsWith('/') && path.length > 1) path = path.replace(/\/+$/, '');
|
||||
parsed.pathname = path || '/';
|
||||
const origin = `${parsed.protocol}//${parsed.host}${path === '/' ? '' : path}`;
|
||||
return normalizeServerBaseUrl(origin);
|
||||
} catch {
|
||||
return normalizeServerBaseUrl(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateCustomHeaders(
|
||||
headers: CustomHeaderEntry[] | undefined,
|
||||
): CustomHeadersValidationResult {
|
||||
if (!headers?.length) return { ok: true };
|
||||
if (headers.length > MAX_CUSTOM_HEADERS) {
|
||||
return {
|
||||
ok: false,
|
||||
fieldErrors: [{ index: 0, field: 'name', messageKey: 'settings.customHeadersValidation.tooMany' }],
|
||||
};
|
||||
}
|
||||
const fieldErrors: CustomHeadersFieldError[] = [];
|
||||
const seen = new Set<string>();
|
||||
headers.forEach((row, index) => {
|
||||
const name = normalizeHeaderName(row.name);
|
||||
const value = row.value;
|
||||
if (!name) {
|
||||
fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.nameRequired' });
|
||||
return;
|
||||
}
|
||||
if (name.length > MAX_HEADER_NAME_LEN) {
|
||||
fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.nameTooLong' });
|
||||
}
|
||||
if (/[\r\n]/.test(name) || /[\r\n]/.test(value)) {
|
||||
fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.crlf' });
|
||||
}
|
||||
if (CUSTOM_HEADER_NAME_BLOCKLIST.has(name.toLowerCase())) {
|
||||
fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.blocked' });
|
||||
}
|
||||
const key = name.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
fieldErrors.push({ index, field: 'name', messageKey: 'settings.customHeadersValidation.duplicate' });
|
||||
}
|
||||
seen.add(key);
|
||||
if (value.length > MAX_HEADER_VALUE_LEN) {
|
||||
fieldErrors.push({ index, field: 'value', messageKey: 'settings.customHeadersValidation.valueTooLong' });
|
||||
}
|
||||
});
|
||||
if (fieldErrors.length) return { ok: false, fieldErrors };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function headersRecord(entries: CustomHeaderEntry[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const row of entries) {
|
||||
const name = normalizeHeaderName(row.name);
|
||||
if (!name) continue;
|
||||
out[name] = row.value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function headersForServerRequest(
|
||||
profile: Pick<
|
||||
ServerProfile,
|
||||
'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo'
|
||||
>,
|
||||
requestBaseUrl: string,
|
||||
): Record<string, string> {
|
||||
if (!profile.customHeaders?.length) return {};
|
||||
const normalized = normalizeServerBaseUrl(requestBaseUrl);
|
||||
const endpoint = serverAddressEndpoints(profile).find(e => e.url === normalized);
|
||||
if (!endpoint) return {};
|
||||
const apply = profile.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO;
|
||||
if (apply === 'both' || apply === endpoint.kind) {
|
||||
return headersRecord(profile.customHeaders);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export type ServerHttpEndpointWire = {
|
||||
url: string;
|
||||
kind: ServerEndpointKind;
|
||||
};
|
||||
|
||||
/** Payload for Rust registry sync — endpoint kinds from TS dual-address layer. */
|
||||
export function serverHttpContextWireForProfile(
|
||||
server: Pick<
|
||||
ServerProfile,
|
||||
'id' | 'url' | 'alternateUrl' | 'customHeaders' | 'customHeadersApplyTo'
|
||||
>,
|
||||
): {
|
||||
serverId: string;
|
||||
appServerId: string;
|
||||
endpoints: ServerHttpEndpointWire[];
|
||||
customHeaders: CustomHeaderEntry[];
|
||||
customHeadersApplyTo: CustomHeadersApplyTo;
|
||||
} {
|
||||
return {
|
||||
serverId: serverIndexKeyForProfile(server),
|
||||
appServerId: server.id,
|
||||
endpoints: serverAddressEndpoints(server).map(e => ({ url: e.url, kind: e.kind })),
|
||||
customHeaders: server.customHeaders ?? [],
|
||||
customHeadersApplyTo: server.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO,
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { flushPlayQueueForServer } from '../../store/queueSync';
|
||||
import { markQueueHandoffPending } from '../../store/queueSyncUiState';
|
||||
import { endOrbitSession, leaveOrbitSession } from '../orbit';
|
||||
import { ensureConnectUrlResolved } from './serverEndpoint';
|
||||
import { syncServerHttpContextForProfile } from './syncServerHttpContext';
|
||||
|
||||
export async function switchActiveServer(server: ServerProfile): Promise<boolean> {
|
||||
coverTrafficBeginServerSwitch();
|
||||
@@ -56,6 +57,7 @@ export async function switchActiveServer(server: ServerProfile): Promise<boolean
|
||||
if (oldActiveId && oldActiveId !== server.id) {
|
||||
markQueueHandoffPending();
|
||||
}
|
||||
void syncServerHttpContextForProfile(server);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const invokeMock = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock }));
|
||||
|
||||
import { syncServerHttpContextForProfile } from './syncServerHttpContext';
|
||||
|
||||
const server = {
|
||||
id: 'app-uuid-1',
|
||||
name: 'Gated',
|
||||
url: 'https://music.example.com',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
customHeaders: [{ name: 'CF-Access-Client-Secret', value: 'secret' }],
|
||||
customHeadersApplyTo: 'public' as const,
|
||||
};
|
||||
|
||||
describe('syncServerHttpContextForProfile', () => {
|
||||
beforeEach(() => {
|
||||
invokeMock.mockClear();
|
||||
});
|
||||
|
||||
it('passes the wire payload under the wire key for Tauri struct args', async () => {
|
||||
await syncServerHttpContextForProfile(server);
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||
expect(invokeMock).toHaveBeenCalledWith('server_http_context_sync', {
|
||||
wire: expect.objectContaining({
|
||||
serverId: expect.any(String),
|
||||
appServerId: 'app-uuid-1',
|
||||
customHeaders: server.customHeaders,
|
||||
customHeadersApplyTo: 'public',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||
import { serverHttpContextWireForProfile } from './serverHttpHeaders';
|
||||
import { serverIndexKeyForProfile } from './serverIndexKey';
|
||||
|
||||
export async function syncServerHttpContextForProfile(server: ServerProfile): Promise<void> {
|
||||
const wire = serverHttpContextWireForProfile(server);
|
||||
await invoke('server_http_context_sync', { wire });
|
||||
}
|
||||
|
||||
export async function syncAllServerHttpContexts(servers: ServerProfile[]): Promise<void> {
|
||||
if (servers.length === 0) return;
|
||||
await invoke('server_http_context_sync_all', {
|
||||
entries: servers.map(s => serverHttpContextWireForProfile(s)),
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearServerHttpContext(server: Pick<ServerProfile, 'id' | 'url'>): Promise<void> {
|
||||
const indexKey = serverIndexKeyForProfile(server);
|
||||
await invoke('server_http_context_clear', { serverId: indexKey, appServerId: server.id });
|
||||
}
|
||||
@@ -133,6 +133,7 @@ describe('share search payload resolution', () => {
|
||||
sharedServer.username,
|
||||
sharedServer.password,
|
||||
'song-1',
|
||||
sharedServer,
|
||||
);
|
||||
expect(mocks.getSong).not.toHaveBeenCalled();
|
||||
expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled();
|
||||
@@ -147,12 +148,14 @@ describe('share search payload resolution', () => {
|
||||
sharedServer.username,
|
||||
sharedServer.password,
|
||||
'album-1',
|
||||
sharedServer,
|
||||
);
|
||||
expect(mocks.getArtistWithCredentials).toHaveBeenCalledWith(
|
||||
sharedServer.url,
|
||||
sharedServer.username,
|
||||
sharedServer.password,
|
||||
'artist-1',
|
||||
sharedServer,
|
||||
);
|
||||
expect(mocks.getAlbum).not.toHaveBeenCalled();
|
||||
expect(mocks.getArtist).not.toHaveBeenCalled();
|
||||
@@ -172,6 +175,7 @@ describe('share search payload resolution', () => {
|
||||
sharedServer.username,
|
||||
sharedServer.password,
|
||||
'composer-1',
|
||||
sharedServer,
|
||||
);
|
||||
expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -110,6 +110,7 @@ async function resolveSharedSong(
|
||||
lookup.server.username,
|
||||
lookup.server.password,
|
||||
id,
|
||||
lookup.server,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -187,6 +188,7 @@ export async function resolveShareSearchAlbum(
|
||||
lookup.server.username,
|
||||
lookup.server.password,
|
||||
payload.id,
|
||||
lookup.server,
|
||||
);
|
||||
return { type: 'ok', album };
|
||||
} catch {
|
||||
@@ -214,6 +216,7 @@ export async function resolveShareSearchArtist(
|
||||
lookup.server.username,
|
||||
lookup.server.password,
|
||||
payload.id,
|
||||
lookup.server,
|
||||
);
|
||||
return { type: 'ok', artist };
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user