mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 22:45:41 +00:00
153 lines
5.0 KiB
TypeScript
153 lines
5.0 KiB
TypeScript
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,
|
|
};
|
|
}
|