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 {
|
||||
|
||||
Reference in New Issue
Block a user