mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-22 05:11:44 +08:00
- 后端改为通过 CONFIG GET databases 动态获取 Redis 数据库数量 - 放宽单机和 Sentinel 模式的 RedisDB 索引限制,支持 db16 及以上 - 前端连接配置和持久化不再裁剪高编号 Redis 数据库 - 连接测试成功后按服务端返回的真实数据库列表展示可选 DB - 增加 Redis db31 展示、切换、保存和 URI 解析回归测试 Refs #558
452 lines
13 KiB
TypeScript
452 lines
13 KiB
TypeScript
import type { ConnectionConfig } from '../types';
|
|
|
|
export type RedisTopology = Extract<
|
|
NonNullable<ConnectionConfig['topology']>,
|
|
'single' | 'cluster' | 'sentinel'
|
|
>;
|
|
|
|
export interface RedisUriFormValues {
|
|
host: string;
|
|
port: number;
|
|
user: string;
|
|
password: string;
|
|
useSSL: boolean;
|
|
sslMode: 'required' | 'skip-verify' | 'disable';
|
|
sslCAPath?: string;
|
|
sslCertPath?: string;
|
|
sslKeyPath?: string;
|
|
redisTopology: RedisTopology;
|
|
redisHosts: string[];
|
|
redisSentinelMaster: string;
|
|
redisSentinelUser: string;
|
|
redisSentinelPassword: string;
|
|
redisDB: number;
|
|
}
|
|
|
|
export interface RedisConfigDraft {
|
|
primaryPort: number;
|
|
hosts: string[];
|
|
topology: RedisTopology;
|
|
redisSentinelMaster: string;
|
|
redisSentinelUser: string;
|
|
redisSentinelPassword: string;
|
|
redisDB: number;
|
|
}
|
|
|
|
const REDIS_DEFAULT_PORT = 6379;
|
|
const REDIS_SENTINEL_DEFAULT_PORT = 26379;
|
|
const MAX_URI_HOSTS = 32;
|
|
|
|
const parseHostPort = (
|
|
raw: string,
|
|
defaultPort: number,
|
|
): { host: string; port: number } | null => {
|
|
const text = String(raw || '').trim();
|
|
if (!text) {
|
|
return null;
|
|
}
|
|
if (text.startsWith('[')) {
|
|
const closingBracket = text.indexOf(']');
|
|
if (closingBracket > 0) {
|
|
const host = text.slice(1, closingBracket).trim();
|
|
const portText = text
|
|
.slice(closingBracket + 1)
|
|
.trim()
|
|
.replace(/^:/, '');
|
|
const parsedPort = Number(portText);
|
|
return {
|
|
host: host || 'localhost',
|
|
port:
|
|
Number.isFinite(parsedPort) && parsedPort > 0 && parsedPort <= 65535
|
|
? parsedPort
|
|
: defaultPort,
|
|
};
|
|
}
|
|
}
|
|
|
|
const colonCount = (text.match(/:/g) || []).length;
|
|
if (colonCount === 1) {
|
|
const splitIndex = text.lastIndexOf(':');
|
|
const host = text.slice(0, splitIndex).trim();
|
|
const portText = text.slice(splitIndex + 1).trim();
|
|
const parsedPort = Number(portText);
|
|
return {
|
|
host: host || 'localhost',
|
|
port:
|
|
Number.isFinite(parsedPort) && parsedPort > 0 && parsedPort <= 65535
|
|
? parsedPort
|
|
: defaultPort,
|
|
};
|
|
}
|
|
|
|
return { host: text, port: defaultPort };
|
|
};
|
|
|
|
const toAddress = (host: string, port: number, defaultPort: number) => {
|
|
const safeHost = String(host || '').trim() || 'localhost';
|
|
const safePort =
|
|
Number.isFinite(Number(port)) && Number(port) > 0
|
|
? Number(port)
|
|
: defaultPort;
|
|
return `${safeHost}:${safePort}`;
|
|
};
|
|
|
|
const normalizeAddressList = (
|
|
rawList: unknown,
|
|
defaultPort: number,
|
|
): string[] => {
|
|
const list = Array.isArray(rawList) ? rawList : [];
|
|
const seen = new Set<string>();
|
|
const result: string[] = [];
|
|
list.forEach((entry) => {
|
|
const parsed = parseHostPort(String(entry || ''), defaultPort);
|
|
if (!parsed) {
|
|
return;
|
|
}
|
|
const normalized = toAddress(parsed.host, parsed.port, defaultPort);
|
|
if (seen.has(normalized)) {
|
|
return;
|
|
}
|
|
seen.add(normalized);
|
|
result.push(normalized);
|
|
});
|
|
return result;
|
|
};
|
|
|
|
const isValidUriHostEntry = (entry: string): boolean => {
|
|
const text = String(entry || '').trim();
|
|
if (!text) return false;
|
|
if (text.length > 255) return false;
|
|
return !/[()\\/\s]/.test(text);
|
|
};
|
|
|
|
const safeDecode = (text: string) => {
|
|
try {
|
|
return decodeURIComponent(text);
|
|
} catch {
|
|
return text;
|
|
}
|
|
};
|
|
|
|
const parseMultiHostUri = (uriText: string, expectedScheme: string) => {
|
|
const prefix = `${expectedScheme}://`;
|
|
if (!uriText.toLowerCase().startsWith(prefix)) {
|
|
return null;
|
|
}
|
|
let rest = uriText.slice(prefix.length);
|
|
const hashIndex = rest.indexOf('#');
|
|
if (hashIndex >= 0) {
|
|
rest = rest.slice(0, hashIndex);
|
|
}
|
|
let queryText = '';
|
|
const queryIndex = rest.indexOf('?');
|
|
if (queryIndex >= 0) {
|
|
queryText = rest.slice(queryIndex + 1);
|
|
rest = rest.slice(0, queryIndex);
|
|
}
|
|
|
|
let pathText = '';
|
|
const slashIndex = rest.indexOf('/');
|
|
if (slashIndex >= 0) {
|
|
pathText = rest.slice(slashIndex + 1);
|
|
rest = rest.slice(0, slashIndex);
|
|
}
|
|
|
|
let hostText = rest;
|
|
let username = '';
|
|
let password = '';
|
|
const atIndex = rest.lastIndexOf('@');
|
|
if (atIndex >= 0) {
|
|
const userInfo = rest.slice(0, atIndex);
|
|
hostText = rest.slice(atIndex + 1);
|
|
const colonIndex = userInfo.indexOf(':');
|
|
if (colonIndex >= 0) {
|
|
username = safeDecode(userInfo.slice(0, colonIndex));
|
|
password = safeDecode(userInfo.slice(colonIndex + 1));
|
|
} else {
|
|
username = safeDecode(userInfo);
|
|
}
|
|
}
|
|
|
|
const hosts = hostText
|
|
.split(',')
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
|
|
return {
|
|
username,
|
|
password,
|
|
hosts,
|
|
database: safeDecode(pathText),
|
|
params: new URLSearchParams(queryText),
|
|
};
|
|
};
|
|
|
|
const firstConnectionParamValue = (
|
|
params: URLSearchParams,
|
|
names: string[],
|
|
): string => {
|
|
for (const name of names) {
|
|
const value = String(params.get(name) || '').trim();
|
|
if (value) return value;
|
|
}
|
|
return '';
|
|
};
|
|
|
|
const extractRedisSSLPathValuesFromParams = (
|
|
params: URLSearchParams,
|
|
): Pick<RedisUriFormValues, 'sslCAPath' | 'sslCertPath' | 'sslKeyPath'> => {
|
|
const caPath = firstConnectionParamValue(params, [
|
|
'sslCAPath',
|
|
'ssl_ca_path',
|
|
'sslrootcert',
|
|
'sslRootCert',
|
|
'tlsCAFile',
|
|
'caFile',
|
|
'certificate',
|
|
'servercertificate',
|
|
'serverCertificate',
|
|
]);
|
|
const certPath = firstConnectionParamValue(params, [
|
|
'sslCertPath',
|
|
'ssl_cert_path',
|
|
'SSL_CERT_PATH',
|
|
'sslcert',
|
|
'sslCert',
|
|
'tlsCertificateFile',
|
|
]);
|
|
const keyPath = firstConnectionParamValue(params, [
|
|
'sslKeyPath',
|
|
'ssl_key_path',
|
|
'SSL_KEY_PATH',
|
|
'sslkey',
|
|
'sslKey',
|
|
'tlsKeyFile',
|
|
]);
|
|
return {
|
|
...(caPath ? { sslCAPath: caPath } : {}),
|
|
...(certPath ? { sslCertPath: certPath } : {}),
|
|
...(keyPath ? { sslKeyPath: keyPath } : {}),
|
|
};
|
|
};
|
|
|
|
const appendRedisSSLPathParamsForUri = (
|
|
params: URLSearchParams,
|
|
values: Record<string, any>,
|
|
) => {
|
|
const caPath = String(values.sslCAPath || '').trim();
|
|
const certPath = String(values.sslCertPath || '').trim();
|
|
const keyPath = String(values.sslKeyPath || '').trim();
|
|
if (caPath) {
|
|
params.set('sslCAPath', caPath);
|
|
}
|
|
if (certPath) {
|
|
params.set('sslCertPath', certPath);
|
|
}
|
|
if (keyPath) {
|
|
params.set('sslKeyPath', keyPath);
|
|
}
|
|
};
|
|
|
|
const normalizeRedisDB = (value: unknown): number => {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
|
return Math.trunc(parsed);
|
|
};
|
|
|
|
const normalizeRedisTopology = (value: unknown): RedisTopology => {
|
|
const text = String(value || '').trim().toLowerCase();
|
|
if (text === 'cluster' || text === 'sentinel') {
|
|
return text;
|
|
}
|
|
return 'single';
|
|
};
|
|
|
|
export const parseRedisUriToFormValues = (
|
|
uriText: string,
|
|
): RedisUriFormValues | null => {
|
|
const trimmedUri = String(uriText || '').trim();
|
|
const parsed =
|
|
parseMultiHostUri(trimmedUri, 'redis') ||
|
|
parseMultiHostUri(trimmedUri, 'rediss');
|
|
if (!parsed) {
|
|
return null;
|
|
}
|
|
if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) {
|
|
return null;
|
|
}
|
|
if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) {
|
|
return null;
|
|
}
|
|
const topologyParam = String(parsed.params.get('topology') || '').toLowerCase();
|
|
const isSentinelTopology = topologyParam === 'sentinel';
|
|
const redisNodeDefaultPort = isSentinelTopology
|
|
? REDIS_SENTINEL_DEFAULT_PORT
|
|
: REDIS_DEFAULT_PORT;
|
|
const hostList = normalizeAddressList(parsed.hosts, redisNodeDefaultPort);
|
|
if (!hostList.length) {
|
|
return null;
|
|
}
|
|
const primary = parseHostPort(
|
|
hostList[0] || `localhost:${redisNodeDefaultPort}`,
|
|
redisNodeDefaultPort,
|
|
);
|
|
const dbText = String(parsed.database || '')
|
|
.trim()
|
|
.replace(/^\//, '');
|
|
const isRediss = trimmedUri.toLowerCase().startsWith('rediss://');
|
|
const skipVerifyText = String(parsed.params.get('skip_verify') || '')
|
|
.trim()
|
|
.toLowerCase();
|
|
const skipVerify =
|
|
skipVerifyText === '1' ||
|
|
skipVerifyText === 'true' ||
|
|
skipVerifyText === 'yes' ||
|
|
skipVerifyText === 'on';
|
|
return {
|
|
host: primary?.host || 'localhost',
|
|
port: primary?.port || redisNodeDefaultPort,
|
|
user: parsed.username || '',
|
|
password: parsed.password || '',
|
|
useSSL: isRediss,
|
|
sslMode: isRediss ? (skipVerify ? 'skip-verify' : 'required') : 'disable',
|
|
...extractRedisSSLPathValuesFromParams(parsed.params),
|
|
redisTopology: isSentinelTopology
|
|
? 'sentinel'
|
|
: hostList.length > 1 || topologyParam === 'cluster'
|
|
? 'cluster'
|
|
: 'single',
|
|
redisHosts: hostList.slice(1),
|
|
redisSentinelMaster: isSentinelTopology
|
|
? String(
|
|
parsed.params.get('master') ||
|
|
parsed.params.get('master_name') ||
|
|
parsed.params.get('sentinel_master') ||
|
|
'',
|
|
).trim()
|
|
: '',
|
|
redisSentinelUser: isSentinelTopology
|
|
? String(
|
|
parsed.params.get('sentinel_user') ||
|
|
parsed.params.get('sentinel_username') ||
|
|
'',
|
|
).trim()
|
|
: '',
|
|
redisSentinelPassword: isSentinelTopology
|
|
? String(parsed.params.get('sentinel_password') || '')
|
|
: '',
|
|
redisDB: normalizeRedisDB(dbText),
|
|
};
|
|
};
|
|
|
|
export const buildRedisUriFromValues = (values: Record<string, any>): string => {
|
|
const redisTopology = normalizeRedisTopology(values.redisTopology);
|
|
const redisNodeDefaultPort =
|
|
redisTopology === 'sentinel'
|
|
? REDIS_SENTINEL_DEFAULT_PORT
|
|
: REDIS_DEFAULT_PORT;
|
|
const primary = toAddress(
|
|
String(values.host || '').trim() || 'localhost',
|
|
Number(values.port || redisNodeDefaultPort),
|
|
redisNodeDefaultPort,
|
|
);
|
|
const extraRedisHosts =
|
|
redisTopology === 'cluster' || redisTopology === 'sentinel'
|
|
? normalizeAddressList(values.redisHosts, redisNodeDefaultPort)
|
|
: [];
|
|
const hosts = normalizeAddressList(
|
|
[primary, ...extraRedisHosts],
|
|
redisNodeDefaultPort,
|
|
);
|
|
const params = new URLSearchParams();
|
|
if (redisTopology === 'sentinel') {
|
|
params.set('topology', 'sentinel');
|
|
const sentinelMaster = String(values.redisSentinelMaster || '').trim();
|
|
if (sentinelMaster) {
|
|
params.set('master', sentinelMaster);
|
|
}
|
|
const sentinelUser = String(values.redisSentinelUser || '').trim();
|
|
if (sentinelUser) {
|
|
params.set('sentinel_user', sentinelUser);
|
|
}
|
|
const sentinelPassword = String(values.redisSentinelPassword || '');
|
|
if (sentinelPassword) {
|
|
params.set('sentinel_password', sentinelPassword);
|
|
}
|
|
} else if (hosts.length > 1 || redisTopology === 'cluster') {
|
|
params.set('topology', 'cluster');
|
|
}
|
|
const redisUser = String(values.user || '').trim();
|
|
const redisPassword = String(values.password || '');
|
|
let redisAuth = '';
|
|
if (redisUser || redisPassword) {
|
|
const encodedPassword = redisPassword
|
|
? encodeURIComponent(redisPassword)
|
|
: '';
|
|
redisAuth = redisUser
|
|
? `${encodeURIComponent(redisUser)}${redisPassword ? `:${encodedPassword}` : ''}@`
|
|
: `:${encodedPassword}@`;
|
|
}
|
|
const redisDB = normalizeRedisDB(values.redisDB);
|
|
if (values.useSSL) {
|
|
const mode = String(values.sslMode || 'preferred')
|
|
.trim()
|
|
.toLowerCase();
|
|
if (mode === 'skip-verify' || mode === 'preferred') {
|
|
params.set('skip_verify', 'true');
|
|
}
|
|
}
|
|
appendRedisSSLPathParamsForUri(params, values);
|
|
const query = params.toString();
|
|
const scheme = values.useSSL ? 'rediss' : 'redis';
|
|
return `${scheme}://${redisAuth}${hosts.join(',')}/${redisDB}${query ? `?${query}` : ''}`;
|
|
};
|
|
|
|
export const resolveRedisConfigDraft = (
|
|
values: Record<string, any>,
|
|
primaryHost: string,
|
|
primaryPort: number,
|
|
defaultPort: number,
|
|
): RedisConfigDraft => {
|
|
const redisTopology = normalizeRedisTopology(values.redisTopology);
|
|
const redisNodeDefaultPort =
|
|
redisTopology === 'sentinel'
|
|
? REDIS_SENTINEL_DEFAULT_PORT
|
|
: defaultPort;
|
|
const normalizedPrimaryPort =
|
|
redisTopology === 'sentinel' &&
|
|
(!Number(values.port) || Number(values.port) === defaultPort)
|
|
? redisNodeDefaultPort
|
|
: primaryPort;
|
|
const extraRedisNodes =
|
|
redisTopology === 'cluster' || redisTopology === 'sentinel'
|
|
? normalizeAddressList(values.redisHosts, redisNodeDefaultPort)
|
|
: [];
|
|
const allHosts = normalizeAddressList(
|
|
[`${primaryHost}:${normalizedPrimaryPort}`, ...extraRedisNodes],
|
|
redisNodeDefaultPort,
|
|
);
|
|
|
|
if (redisTopology === 'sentinel') {
|
|
return {
|
|
primaryPort: normalizedPrimaryPort,
|
|
hosts: allHosts,
|
|
topology: 'sentinel',
|
|
redisSentinelMaster: String(values.redisSentinelMaster || '').trim(),
|
|
redisSentinelUser: String(values.redisSentinelUser || '').trim(),
|
|
redisSentinelPassword: String(values.redisSentinelPassword || ''),
|
|
redisDB: normalizeRedisDB(values.redisDB),
|
|
};
|
|
}
|
|
|
|
return {
|
|
primaryPort: normalizedPrimaryPort,
|
|
hosts: redisTopology === 'cluster' || allHosts.length > 1 ? allHosts : [],
|
|
topology: redisTopology === 'cluster' || allHosts.length > 1 ? 'cluster' : 'single',
|
|
redisSentinelMaster: '',
|
|
redisSentinelUser: '',
|
|
redisSentinelPassword: '',
|
|
redisDB: normalizeRedisDB(values.redisDB),
|
|
};
|
|
};
|