feat(server): custom HTTP headers for reverse-proxy gates (#1095) (#1156)

This commit is contained in:
cucadmuh
2026-06-22 16:25:28 +03:00
committed by GitHub
parent 2c9b2eeb46
commit 15cecb5d7d
83 changed files with 2452 additions and 327 deletions
+37 -1
View File
@@ -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();
});
});
+15
View File
@@ -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
View File
@@ -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),
);
}
+16
View File
@@ -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,
+10 -1
View File
@@ -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 ?? [] };
+11
View File
@@ -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');
});
});
+3 -1
View File
@@ -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 {
+6
View File
@@ -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');
});
});
+144 -1
View File
@@ -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>
+26 -7
View File
@@ -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,
+1
View File
@@ -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)',
],
},
{
+17 -16
View File
@@ -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);
});
});
+18
View File
@@ -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',
+18
View File
@@ -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',
+18
View File
@@ -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',
+18
View File
@@ -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 len-tête',
customHeadersValuePlaceholder: 'Valeur de len-tête',
customHeadersAddRow: 'Ajouter un en-tête',
customHeadersRemoveRow: 'Supprimer len-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 den-têtes personnalisés (max. 16).',
'customHeadersValidation.nameRequired': 'Le nom de len-tête est requis.',
'customHeadersValidation.nameTooLong': 'Le nom de len-tête est trop long.',
'customHeadersValidation.valueTooLong': 'La valeur de len-tête est trop longue.',
'customHeadersValidation.crlf': 'Les noms et valeurs ne peuvent pas contenir de saut de ligne.',
'customHeadersValidation.blocked': 'Ce nom den-tête nest pas autorisé.',
'customHeadersValidation.duplicate': 'Nom den-tête en double.',
addServer: 'Ajouter un serveur',
addServerTitle: 'Ajouter un nouveau serveur',
editServer: 'Modifier',
+18
View File
@@ -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',
+18
View File
@@ -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: '編集',
+18
View File
@@ -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',
+18
View File
@@ -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',
+18
View File
@@ -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ă',
+18
View File
@@ -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: 'Изменить',
+18
View File
@@ -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: '编辑',
+2
View File
@@ -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);
},
}
)
+21
View File
@@ -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),
+2
View File
@@ -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';
+11
View File
@@ -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);
});
});
+9 -1
View File
@@ -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))));
}
+40 -39
View File
@@ -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();
+6 -7
View File
@@ -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);
+62 -9
View File
@@ -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 });
});
});
+152
View File
@@ -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,
};
}
+2
View File
@@ -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',
}),
});
});
});
+21
View File
@@ -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 {