mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
121 lines
3.7 KiB
TypeScript
121 lines
3.7 KiB
TypeScript
import {domainToASCII} from 'node:url';
|
|
import type {RedisOptions} from 'ioredis';
|
|
|
|
interface KVClusterConnection {
|
|
node: {host: string; port: number};
|
|
redisOptions: RedisOptions;
|
|
}
|
|
|
|
export function resolveKVClusterConnection(url: string): KVClusterConnection {
|
|
const normalizedUrl = url.trim();
|
|
for (let index = 0; index < normalizedUrl.length; index++) {
|
|
const code = normalizedUrl.charCodeAt(index);
|
|
if (code <= 0x1f || code === 0x7f) {
|
|
throw new Error('KV cluster URL must not contain control characters');
|
|
}
|
|
}
|
|
const parsed = parseClusterUrl(normalizedUrl);
|
|
const authorityStart = normalizedUrl.indexOf('://');
|
|
if (authorityStart === -1) {
|
|
throw new Error('Invalid KV cluster URL');
|
|
}
|
|
const authorityAndPath = normalizedUrl.slice(authorityStart + 3).split(/[?#]/, 1)[0];
|
|
const pathStart = authorityAndPath.indexOf('/');
|
|
const authority = pathStart === -1 ? authorityAndPath : authorityAndPath.slice(0, pathStart);
|
|
const path = pathStart === -1 ? '' : authorityAndPath.slice(pathStart + 1);
|
|
const queryNames = new Set<string>();
|
|
for (const name of parsed.searchParams.keys()) {
|
|
if (name !== 'username' && name !== 'password' && name !== 'db') {
|
|
throw new Error('Unsupported KV cluster URL option');
|
|
}
|
|
if (queryNames.has(name)) {
|
|
throw new Error('Duplicate KV cluster URL option');
|
|
}
|
|
queryNames.add(name);
|
|
}
|
|
if (path.length > 0) {
|
|
validateClusterDatabase(path);
|
|
}
|
|
const database = parsed.searchParams.get('db');
|
|
if (database !== null) {
|
|
validateClusterDatabase(database);
|
|
}
|
|
const redisOptions = resolveClusterAuthentication(authority, parsed.searchParams);
|
|
if (parsed.protocol === 'rediss:') {
|
|
redisOptions.tls = {};
|
|
}
|
|
const host = resolveClusterHost(authority, parsed);
|
|
return {
|
|
node: {host, port: Number(parsed.port || '6379')},
|
|
redisOptions,
|
|
};
|
|
}
|
|
|
|
function parseClusterUrl(url: string): URL {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(url);
|
|
} catch {
|
|
throw new Error('Invalid KV cluster URL');
|
|
}
|
|
if (parsed.protocol !== 'redis:' && parsed.protocol !== 'rediss:') {
|
|
throw new Error('KV cluster URL must use redis:// or rediss://');
|
|
}
|
|
if (!parsed.hostname) {
|
|
throw new Error('KV cluster URL must include a host');
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function resolveClusterHost(authority: string, parsed: URL): string {
|
|
if (parsed.hostname.startsWith('[')) {
|
|
return parsed.hostname.slice(1, -1);
|
|
}
|
|
const hostAndPort = authority.slice(authority.lastIndexOf('@') + 1);
|
|
const portStart = hostAndPort.lastIndexOf(':');
|
|
const hostname = portStart === -1 ? hostAndPort : hostAndPort.slice(0, portStart);
|
|
if (hostname.includes('%')) {
|
|
throw new Error('KV cluster URL must not contain a percent-encoded hostname');
|
|
}
|
|
const host = domainToASCII(hostname.toLowerCase());
|
|
if (!host) {
|
|
throw new Error('Invalid KV cluster URL hostname');
|
|
}
|
|
return host;
|
|
}
|
|
|
|
function validateClusterDatabase(database: string): void {
|
|
if (!/^0+$/.test(database)) {
|
|
throw new Error('KV cluster URL must select database 0');
|
|
}
|
|
}
|
|
|
|
function resolveClusterAuthentication(authority: string, query: URLSearchParams): RedisOptions {
|
|
const authEnd = authority.lastIndexOf('@');
|
|
let auth = '';
|
|
if (authEnd !== -1) {
|
|
try {
|
|
auth = decodeURIComponent(authority.slice(0, authEnd));
|
|
} catch {
|
|
throw new Error('Invalid KV cluster URL credentials');
|
|
}
|
|
}
|
|
if (auth) {
|
|
const separator = auth.indexOf(':');
|
|
return {
|
|
username: separator === -1 ? auth : auth.slice(0, separator),
|
|
password: separator === -1 ? '' : auth.slice(separator + 1),
|
|
};
|
|
}
|
|
const options: RedisOptions = {};
|
|
const username = query.get('username');
|
|
const password = query.get('password');
|
|
if (username !== null) {
|
|
options.username = username;
|
|
}
|
|
if (password !== null) {
|
|
options.password = password;
|
|
}
|
|
return options;
|
|
}
|