mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
This commit is contained in:
@@ -16,6 +16,12 @@ vi.mock('@/api/subsonic', () => ({
|
||||
serverVersion: '0.55.0',
|
||||
openSubsonic: true,
|
||||
})),
|
||||
pingWithCredentialsForProfile: vi.fn(async () => ({
|
||||
ok: true,
|
||||
type: 'navidrome',
|
||||
serverVersion: '0.55.0',
|
||||
openSubsonic: true,
|
||||
})),
|
||||
scheduleInstantMixProbeForServer: vi.fn(),
|
||||
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
|
||||
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
|
||||
|
||||
@@ -143,3 +143,33 @@ describe('AddServerForm — dual-address behaviour', () => {
|
||||
expect(arg).not.toHaveProperty('shareUsesLocalUrl');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AddServerForm — custom HTTP headers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('includes configured custom headers on save', async () => {
|
||||
const onSave = vi.fn();
|
||||
renderWithProviders(<AddServerForm onSave={onSave} onCancel={vi.fn()} />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
const inputs = screen.getAllByRole('textbox');
|
||||
await user.type(inputs[1]!, 'https://music.example.com');
|
||||
await user.type(inputs[3]!, 'tester');
|
||||
await user.type(screen.getByPlaceholderText('••••••••'), 'pw');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /custom http headers/i }));
|
||||
const headerNameInputs = screen.getAllByPlaceholderText(/header name/i);
|
||||
const headerValueInputs = screen.getAllByPlaceholderText(/header value/i);
|
||||
await user.type(headerNameInputs[0]!, 'CF-Access-Client-Secret');
|
||||
await user.type(headerValueInputs[0]!, 'gate-secret');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Add' }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
const arg = onSave.mock.calls[0]![0];
|
||||
expect(arg.customHeaders).toEqual([{ name: 'CF-Access-Client-Secret', value: 'gate-secret' }]);
|
||||
expect(arg.customHeadersApplyTo).toBe('public');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||
import type { CustomHeaderEntry, CustomHeadersApplyTo, ServerProfile } from '../../store/authStoreTypes';
|
||||
import { showToast } from '../../utils/ui/toast';
|
||||
import {
|
||||
DEFAULT_CUSTOM_HEADERS_APPLY_TO,
|
||||
validateCustomHeaders,
|
||||
} from '../../utils/server/serverHttpHeaders';
|
||||
import {
|
||||
decodeServerMagicString,
|
||||
encodeServerMagicString,
|
||||
@@ -19,6 +23,9 @@ type FormState = {
|
||||
shareUsesLocalUrl: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
customHeaders: CustomHeaderEntry[];
|
||||
customHeadersApplyTo: CustomHeadersApplyTo;
|
||||
customHeadersOpen: boolean;
|
||||
};
|
||||
|
||||
/** Hostname for the DNS-based form hint, or null when the input is a literal IP. */
|
||||
@@ -62,6 +69,12 @@ export function AddServerForm({
|
||||
shareUsesLocalUrl: editingServer.shareUsesLocalUrl ?? false,
|
||||
username: editingServer.username,
|
||||
password: editingServer.password,
|
||||
customHeaders: editingServer.customHeaders?.length
|
||||
? editingServer.customHeaders.map(h => ({ ...h }))
|
||||
: [{ name: '', value: '' }],
|
||||
customHeadersApplyTo:
|
||||
editingServer.customHeadersApplyTo ?? DEFAULT_CUSTOM_HEADERS_APPLY_TO,
|
||||
customHeadersOpen: Boolean(editingServer.customHeaders?.length),
|
||||
}
|
||||
: {
|
||||
name: '',
|
||||
@@ -70,6 +83,9 @@ export function AddServerForm({
|
||||
shareUsesLocalUrl: false,
|
||||
username: '',
|
||||
password: '',
|
||||
customHeaders: [{ name: '', value: '' }],
|
||||
customHeadersApplyTo: DEFAULT_CUSTOM_HEADERS_APPLY_TO,
|
||||
customHeadersOpen: false,
|
||||
},
|
||||
);
|
||||
const [magicString, setMagicString] = useState('');
|
||||
@@ -175,6 +191,20 @@ export function AddServerForm({
|
||||
return true;
|
||||
};
|
||||
|
||||
const customHeadersPayload = (): Pick<
|
||||
ServerProfile,
|
||||
'customHeaders' | 'customHeadersApplyTo'
|
||||
> => {
|
||||
const rows = form.customHeaders
|
||||
.map(h => ({ name: h.name.trim(), value: h.value }))
|
||||
.filter(h => h.name || h.value);
|
||||
if (!rows.length) return {};
|
||||
return {
|
||||
customHeaders: rows,
|
||||
customHeadersApplyTo: form.customHeadersApplyTo,
|
||||
};
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const ms = magicString.trim();
|
||||
if (ms) {
|
||||
@@ -193,6 +223,7 @@ export function AddServerForm({
|
||||
url: decoded.url,
|
||||
username: decoded.username,
|
||||
password: decoded.password,
|
||||
...customHeadersPayload(),
|
||||
...(altDecoded
|
||||
? {
|
||||
alternateUrl: altDecoded,
|
||||
@@ -205,6 +236,15 @@ export function AddServerForm({
|
||||
if (!form.url.trim()) return;
|
||||
if (!validateAddresses()) return;
|
||||
|
||||
const headerValidation = validateCustomHeaders(
|
||||
form.customHeaders.filter(h => h.name.trim() || h.value),
|
||||
);
|
||||
if (!headerValidation.ok) {
|
||||
const first = headerValidation.fieldErrors[0];
|
||||
showToast(t(first.messageKey, { defaultValue: first.messageKey }), 5000, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const altTrimmed = form.alternateUrl.trim();
|
||||
// If the user clears the second address, strip the share-flag as well so
|
||||
// we don't leave a dangling preference (spec §5.3 last row).
|
||||
@@ -213,6 +253,7 @@ export function AddServerForm({
|
||||
url: form.url.trim(),
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
...customHeadersPayload(),
|
||||
...(altTrimmed
|
||||
? {
|
||||
alternateUrl: altTrimmed,
|
||||
@@ -338,6 +379,108 @@ export function AddServerForm({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 13, padding: '4px 0' }}
|
||||
onClick={() => setForm(f => ({ ...f, customHeadersOpen: !f.customHeadersOpen }))}
|
||||
>
|
||||
{form.customHeadersOpen ? '▾' : '▸'} {t('settings.customHeadersTitle')}
|
||||
</button>
|
||||
{form.customHeadersOpen && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<p style={{ fontSize: 11, opacity: 0.75, margin: '0 0 8px' }}>
|
||||
{t('settings.customHeadersHelp')}
|
||||
</p>
|
||||
{form.customHeaders.map((row, index) => (
|
||||
<div key={index} className="form-row" style={{ marginBottom: 6, gap: 8 }}>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={row.name}
|
||||
onChange={e => {
|
||||
const name = e.target.value;
|
||||
setForm(f => {
|
||||
const customHeaders = f.customHeaders.map((h, i) =>
|
||||
i === index ? { ...h, name } : h,
|
||||
);
|
||||
return { ...f, customHeaders };
|
||||
});
|
||||
}}
|
||||
placeholder={t('settings.customHeadersNamePlaceholder')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={row.value}
|
||||
onChange={e => {
|
||||
const value = e.target.value;
|
||||
setForm(f => ({
|
||||
...f,
|
||||
customHeaders: f.customHeaders.map((h, i) =>
|
||||
i === index ? { ...h, value } : h,
|
||||
),
|
||||
}));
|
||||
}}
|
||||
placeholder={t('settings.customHeadersValuePlaceholder')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
aria-label={t('settings.customHeadersRemoveRow')}
|
||||
onClick={() =>
|
||||
setForm(f => ({
|
||||
...f,
|
||||
customHeaders:
|
||||
f.customHeaders.length <= 1
|
||||
? [{ name: '', value: '' }]
|
||||
: f.customHeaders.filter((_, i) => i !== index),
|
||||
}))
|
||||
}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, marginBottom: 8 }}
|
||||
onClick={() =>
|
||||
setForm(f => ({
|
||||
...f,
|
||||
customHeaders: [...f.customHeaders, { name: '', value: '' }],
|
||||
}))
|
||||
}
|
||||
>
|
||||
{t('settings.customHeadersAddRow')}
|
||||
</button>
|
||||
<fieldset
|
||||
disabled={!form.customHeaders.some(h => h.name.trim() || h.value)}
|
||||
style={{ border: 'none', padding: 0, margin: 0 }}
|
||||
>
|
||||
<legend style={{ fontSize: 12, marginBottom: 4 }}>{t('settings.customHeadersApplyTo')}</legend>
|
||||
{(['public', 'local', 'both'] as const).map(kind => (
|
||||
<label key={kind} style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="customHeadersApplyTo"
|
||||
checked={form.customHeadersApplyTo === kind}
|
||||
onChange={() => setForm(f => ({ ...f, customHeadersApplyTo: kind }))}
|
||||
/>{' '}
|
||||
{t(`settings.customHeadersApplyTo_${kind}`)}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
<p style={{ fontSize: 11, opacity: 0.65, marginTop: 6 }}>
|
||||
{t('settings.customHeadersNotInShare')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isEdit && (
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ fontSize: 13 }}>{t('login.orMagicString')}</label>
|
||||
|
||||
@@ -12,7 +12,11 @@ import { bootstrapIndexedServer } from '../../utils/library/librarySession';
|
||||
import { useLibraryIndexSync } from '../../hooks/useLibraryIndexSync';
|
||||
import ServerLibraryIndexControls from './ServerLibraryIndexControls';
|
||||
import type { ServerProfile } from '../../store/authStoreTypes';
|
||||
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic';
|
||||
import { pingWithCredentialsForProfile, scheduleInstantMixProbeForServer } from '../../api/subsonic';
|
||||
import {
|
||||
clearServerHttpContext,
|
||||
syncServerHttpContextForProfile,
|
||||
} from '../../utils/server/syncServerHttpContext';
|
||||
import { useDragDrop } from '../../contexts/DragDropContext';
|
||||
import { type ServerMagicPayload } from '../../utils/server/serverMagicString';
|
||||
import { ensureConnectUrlResolved, invalidateReachableEndpointCache } from '../../utils/server/serverEndpoint';
|
||||
@@ -191,6 +195,7 @@ export function ServersTab({
|
||||
|
||||
auth.removeServer(server.id);
|
||||
try {
|
||||
await clearServerHttpContext(server);
|
||||
await librarySyncClearSession(server.id);
|
||||
if (purgeLibrary) {
|
||||
await libraryDeleteServerData(server.id);
|
||||
@@ -238,7 +243,12 @@ export function ServersTab({
|
||||
// straight to the legacy ping (which is also the connect-test).
|
||||
if (data.alternateUrl) {
|
||||
const verify = await verifySameServerEndpoints(
|
||||
{ url: data.url, alternateUrl: data.alternateUrl },
|
||||
{
|
||||
url: data.url,
|
||||
alternateUrl: data.alternateUrl,
|
||||
customHeaders: data.customHeaders,
|
||||
customHeadersApplyTo: data.customHeadersApplyTo,
|
||||
},
|
||||
data.username,
|
||||
data.password,
|
||||
);
|
||||
@@ -247,7 +257,7 @@ export function ServersTab({
|
||||
return;
|
||||
}
|
||||
}
|
||||
const ping = await pingWithCredentials(data.url, data.username, data.password);
|
||||
const ping = await pingWithCredentialsForProfile(data, data.url);
|
||||
if (ping.ok) {
|
||||
const id = auth.addServer(data);
|
||||
const identity = {
|
||||
@@ -259,7 +269,10 @@ export function ServersTab({
|
||||
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity, true);
|
||||
setConnStatus(s => ({ ...s, [id]: 'ok' }));
|
||||
const added = useAuthStore.getState().servers.find(s => s.id === id);
|
||||
if (added) void bootstrapIndexedServer(added);
|
||||
if (added) {
|
||||
void syncServerHttpContextForProfile(added);
|
||||
void bootstrapIndexedServer(added);
|
||||
}
|
||||
} else {
|
||||
setConnStatus(s => ({ ...s, [tempId]: 'error' }));
|
||||
}
|
||||
@@ -323,7 +336,12 @@ export function ServersTab({
|
||||
if (dualAddressChanged) {
|
||||
setConnStatus(s => ({ ...s, [id]: 'testing' }));
|
||||
const verify = await verifySameServerEndpoints(
|
||||
{ url: data.url, alternateUrl: data.alternateUrl },
|
||||
{
|
||||
url: data.url,
|
||||
alternateUrl: data.alternateUrl,
|
||||
customHeaders: data.customHeaders,
|
||||
customHeadersApplyTo: data.customHeadersApplyTo,
|
||||
},
|
||||
data.username,
|
||||
data.password,
|
||||
);
|
||||
@@ -335,12 +353,13 @@ export function ServersTab({
|
||||
|
||||
setEditingServerId(null);
|
||||
auth.updateServer(id, data);
|
||||
const updated = useAuthStore.getState().servers.find(s => s.id === id);
|
||||
if (updated) void syncServerHttpContextForProfile(updated);
|
||||
// Profile edited → any cached sticky connect URL for this id may now be
|
||||
// stale (credentials may have changed, alternate may have been added).
|
||||
invalidateReachableEndpointCache(id);
|
||||
setConnStatus(s => ({ ...s, [id]: 'testing' }));
|
||||
try {
|
||||
const ping = await pingWithCredentials(data.url, data.username, data.password);
|
||||
const ping = await pingWithCredentialsForProfile(data, data.url);
|
||||
if (ping.ok) {
|
||||
const identity = {
|
||||
type: ping.type,
|
||||
|
||||
Reference in New Issue
Block a user