Files
MyGoNavi/frontend/src/utils/connectionReadOnly.ts
AutumnNazi ad7179941f 🐛 fix(connection): 修复连接环境标识与生产操作保护
- 分组不再覆盖连接自身环境类型,Tab 和侧栏视图统一使用连接环境颜色\n- 为未配置生产保护的生产连接增加 5 秒风险确认,覆盖 SQL、数据、结构、同步、Redis、Nacos 和消息操作\n- 写操作完成后等待刷新请求结束再提示成功,避免界面状态落后于实际结果\n- 增加连接环境、生产风险确认和刷新竞态测试,关联 Issue #823
2026-08-03 16:39:06 +08:00

580 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ConnectionConfig } from "../types";
import { convertMongoShellToJsonCommand } from "./mongodb";
import { resolveSqlDialect } from "./sqlDialect";
import { findSqlStatementRanges } from "./sqlStatementSelection";
export type ConnectionProtectionKey =
| "restrictDataEdit"
| "restrictStructureEdit"
| "restrictScriptExecution"
| "restrictDataImport";
export type ConnectionProtectionConfig = NonNullable<
ConnectionConfig["protection"]
>;
type ConnectionReadOnlyLike = Pick<
ConnectionConfig,
"type" | "driver" | "oceanBaseProtocol" | "readOnly" | "protection"
> | null | undefined;
export const CONNECTION_PROTECTION_KEYS: ConnectionProtectionKey[] = [
"restrictDataEdit",
"restrictStructureEdit",
"restrictScriptExecution",
"restrictDataImport",
];
const EMPTY_CONNECTION_PROTECTION: ConnectionProtectionConfig = {
restrictDataEdit: false,
restrictStructureEdit: false,
restrictScriptExecution: false,
restrictDataImport: false,
};
const FULL_CONNECTION_PROTECTION: ConnectionProtectionConfig = {
restrictDataEdit: true,
restrictStructureEdit: true,
restrictScriptExecution: true,
restrictDataImport: true,
};
const CONNECTION_READ_ONLY_TYPES = new Set([
"mysql",
"goldendb",
"mariadb",
"oceanbase",
"diros",
"starrocks",
"sphinx",
"postgres",
"kingbase",
"highgo",
"vastbase",
"opengauss",
"gaussdb",
"sqlserver",
"iris",
"sqlite",
"duckdb",
"oracle",
"dameng",
"tdengine",
"clickhouse",
"trino",
"mongodb",
"elasticsearch",
]);
const CONNECTION_PROTECTION_TYPES = new Set([
...CONNECTION_READ_ONLY_TYPES,
"nacos",
]);
export const MAX_CONNECTION_KEEPALIVE_SQL_LENGTH = 4096;
const SQL_READ_ONLY_KEYWORDS = new Set([
"select",
"with",
"show",
"describe",
"desc",
"explain",
"pragma",
"values",
"consume",
]);
const SQL_MUTATING_WITH_KEYWORDS = /\b(insert|update|delete|replace|merge|upsert)\b/i;
const SQL_SELECT_INTO_PATTERN = /^\s*select\b[\s\S]*\binto\b/i;
const MONGO_READ_ONLY_COMMANDS = new Set([
"aggregate",
"buildinfo",
"collstats",
"connectionstatus",
"count",
"countdocuments",
"dbstats",
"distinct",
"explain",
"find",
"findone",
"getparameter",
"hello",
"hostinfo",
"ismaster",
"listcollections",
"listdatabases",
"listindexes",
"ping",
"serverstatus",
]);
const MONGO_WRITE_COMMANDS = new Set([
"bulkwrite",
"collmod",
"create",
"createindexes",
"delete",
"drop",
"dropdatabase",
"dropindexes",
"findandmodify",
"insert",
"mapreduce",
"renamecollection",
"update",
]);
const MONGO_META_KEYS = new Set([
"$db",
"$readpreference",
"api",
"apideprecationerrors",
"apistrict",
"comment",
"let",
"lsid",
"maxtimems",
"ordered",
"readconcern",
"writeconcern",
]);
const MYSQL_COMMENT_DIALECTS = new Set([
"mysql",
"goldendb",
"mariadb",
"oceanbase",
"diros",
"starrocks",
"sphinx",
"tidb",
]);
const isDashLineCommentStart = (text: string, dbType: string): boolean => {
if (!MYSQL_COMMENT_DIALECTS.has(dbType)) return true;
const next = text[2] || "";
return !next || /\s/.test(next);
};
const supportsHashLineComment = (dbType: string): boolean =>
!dbType || dbType === "clickhouse" || MYSQL_COMMENT_DIALECTS.has(dbType);
const isExecutableBlockComment = (text: string, dbType: string): boolean => {
if (text.slice(0, 4).toLowerCase() === "/*m!") return dbType === "mariadb";
return text.startsWith("/*!") && MYSQL_COMMENT_DIALECTS.has(dbType);
};
const stripLeadingSqlComments = (statement: string, dbType = ""): string => {
let text = String(statement || "").trim();
while (text) {
if (text.startsWith("--") && isDashLineCommentStart(text, dbType)) {
const next = text.indexOf("\n");
text = next >= 0 ? text.slice(next + 1).trimStart() : "";
continue;
}
if (text.startsWith("#") && supportsHashLineComment(dbType)) {
const next = text.indexOf("\n");
text = next >= 0 ? text.slice(next + 1).trimStart() : "";
continue;
}
if (text.startsWith("/*") && !isExecutableBlockComment(text, dbType)) {
const next = text.indexOf("*/");
text = next >= 0 ? text.slice(next + 2).trimStart() : "";
continue;
}
break;
}
return text;
};
const extractLeadingSqlKeyword = (statement: string, dbType = ""): string => {
const text = stripLeadingSqlComments(statement, dbType);
const match = text.match(/^[A-Za-z_][A-Za-z0-9_]*/);
return match ? match[0].toLowerCase() : "";
};
const resolveConnectionReadOnlyType = (
config: ConnectionReadOnlyLike,
): string => {
if (!config) return "";
return String(
resolveSqlDialect(String(config.type || ""), String(config.driver || ""), {
oceanBaseProtocol: config.oceanBaseProtocol,
}),
)
.trim()
.toLowerCase();
};
const isReadOnlySqlStatement = (statement: string, dbType: string): boolean => {
const text = stripLeadingSqlComments(statement, dbType);
if (!text) return true;
const keyword = extractLeadingSqlKeyword(text, dbType);
if (!keyword || !SQL_READ_ONLY_KEYWORDS.has(keyword)) {
return false;
}
if (keyword === "select") {
return !SQL_SELECT_INTO_PATTERN.test(text);
}
if (keyword === "with") {
return !SQL_SELECT_INTO_PATTERN.test(text) &&
!SQL_MUTATING_WITH_KEYWORDS.test(text);
}
return true;
};
const normalizeMongoCommandText = (statement: string): string => {
const trimmed = String(statement || "").trim();
if (!trimmed) return "";
if (trimmed.startsWith("{")) {
return trimmed;
}
const converted = convertMongoShellToJsonCommand(trimmed);
if (converted.recognized && converted.command) {
return converted.command;
}
return "";
};
const resolveMongoCommandKey = (command: Record<string, unknown>): string => {
const keys = Object.keys(command).map((key) => key.trim());
const effectiveKeys = keys.filter((key) => {
const normalized = key.toLowerCase();
return normalized !== "" && !MONGO_META_KEYS.has(normalized);
});
for (const key of effectiveKeys) {
if (MONGO_WRITE_COMMANDS.has(key.toLowerCase())) {
return key;
}
}
for (const key of effectiveKeys) {
if (MONGO_READ_ONLY_COMMANDS.has(key.toLowerCase())) {
return key;
}
}
return effectiveKeys[0] || "";
};
const isReadOnlyMongoStatement = (statement: string): boolean => {
const commandText = normalizeMongoCommandText(statement);
if (!commandText) return false;
try {
const parsed = JSON.parse(commandText) as Record<string, unknown>;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return false;
}
const commandKey = resolveMongoCommandKey(parsed).toLowerCase();
if (!commandKey) return false;
if (MONGO_WRITE_COMMANDS.has(commandKey)) {
return false;
}
return MONGO_READ_ONLY_COMMANDS.has(commandKey);
} catch {
return false;
}
};
const isConnectionReadOnlyStatement = (
config: ConnectionReadOnlyLike,
statement: string,
): boolean => {
const dialect = resolveConnectionReadOnlyType(config);
if (dialect === "mongodb") {
return isReadOnlyMongoStatement(statement);
}
return isReadOnlySqlStatement(statement, dialect);
};
const KEEPALIVE_EXECUTABLE_COMMENT_DIALECTS = new Set([
"mysql",
"mariadb",
"oceanbase",
"diros",
"starrocks",
]);
const skipKeepAliveLineComment = (text: string, start: number): number => {
let index = start;
while (index < text.length && text[index] !== "\n" && text[index] !== "\r") {
index += 1;
}
return index;
};
const skipKeepAliveQuotedText = (
text: string,
start: number,
quote: string,
): number => {
for (let index = start + 1; index < text.length; index += 1) {
if (text[index] === "\\" && index + 1 < text.length) {
index += 1;
continue;
}
if (text[index] !== quote) continue;
if (index + 1 < text.length && text[index + 1] === quote) {
index += 1;
continue;
}
return index + 1;
}
return text.length;
};
const hasExecutableKeepAliveComment = (
text: string,
dbType: string,
): boolean => {
if (!KEEPALIVE_EXECUTABLE_COMMENT_DIALECTS.has(dbType)) return false;
for (let index = 0; index < text.length;) {
const remaining = text.slice(index);
if (
remaining.startsWith("/*!") ||
remaining.slice(0, 4).toLowerCase() === "/*m!"
) {
return true;
}
const current = text[index];
if (current === "'" || current === '"' || current === "`") {
index = skipKeepAliveQuotedText(text, index, current);
continue;
}
if (current === "#") {
index = skipKeepAliveLineComment(text, index + 1);
continue;
}
if (remaining.startsWith("--") && isDashLineCommentStart(remaining, dbType)) {
index = skipKeepAliveLineComment(text, index + 2);
continue;
}
if (remaining.startsWith("/*")) {
const end = remaining.indexOf("*/", 2);
index = end >= 0 ? index + end + 2 : text.length;
continue;
}
index += 1;
}
return false;
};
const isKeepAliveWhitespace = (character: string): boolean =>
character === " " ||
character === "\t" ||
character === "\n" ||
character === "\r" ||
character === "\f" ||
character === "\v";
const containsOnlyTrailingKeepAliveTrivia = (
text: string,
dbType: string,
): boolean => {
for (let index = 0; index < text.length;) {
const remaining = text.slice(index);
if (isKeepAliveWhitespace(text[index])) {
index += 1;
continue;
}
if (remaining.startsWith("--") && isDashLineCommentStart(remaining, dbType)) {
index = skipKeepAliveLineComment(text, index + 2);
continue;
}
if (text[index] === "#" && supportsHashLineComment(dbType)) {
index = skipKeepAliveLineComment(text, index + 1);
continue;
}
if (remaining.startsWith("/*")) {
const end = remaining.indexOf("*/", 2);
if (end < 0) return false;
index += end + 2;
continue;
}
return false;
}
return true;
};
const hasSafeKeepAliveStatementDelimiter = (
text: string,
dbType: string,
): boolean => {
const asciiCount = text.split(";").length - 1;
const fullWidthCount = text.split("").length - 1;
if (asciiCount + fullWidthCount === 0) return true;
if (asciiCount + fullWidthCount !== 1) return false;
const delimiter = asciiCount === 1 ? ";" : "";
const index = text.indexOf(delimiter);
return containsOnlyTrailingKeepAliveTrivia(
text.slice(index + delimiter.length),
dbType,
);
};
export const supportsConnectionKeepAliveSQL = (
config: ConnectionReadOnlyLike,
): boolean => {
const type = resolveConnectionReadOnlyType(config);
return (
type !== "mongodb" &&
type !== "elasticsearch" &&
type !== "sqlite" &&
type !== "duckdb" &&
CONNECTION_READ_ONLY_TYPES.has(type)
);
};
export const isSingleReadOnlyConnectionQuery = (
config: ConnectionReadOnlyLike,
sql: string,
): boolean => {
if (!supportsConnectionKeepAliveSQL(config)) return false;
const dialect = resolveConnectionReadOnlyType(config);
const text = String(sql || "");
if (
hasExecutableKeepAliveComment(text, dialect) ||
!hasSafeKeepAliveStatementDelimiter(text, dialect)
) {
return false;
}
const statements = findSqlStatementRanges(text, dialect)
.map((range) => range.text.trim())
.filter(Boolean);
if (statements.length !== 1) return false;
const keyword = extractLeadingSqlKeyword(statements[0], dialect);
return (
(keyword === "select" || keyword === "with") &&
isConnectionReadOnlyStatement(config, statements[0])
);
};
export const supportsConnectionReadOnlyMode = (
config: ConnectionReadOnlyLike,
): boolean => {
return CONNECTION_PROTECTION_TYPES.has(resolveConnectionReadOnlyType(config));
};
export const normalizeConnectionProtectionConfig = (
value: unknown,
): ConnectionProtectionConfig => {
const raw =
value && typeof value === "object"
? (value as Record<string, unknown>)
: {};
return {
restrictDataEdit: raw.restrictDataEdit === true,
restrictStructureEdit: raw.restrictStructureEdit === true,
restrictScriptExecution: raw.restrictScriptExecution === true,
restrictDataImport: raw.restrictDataImport === true,
};
};
export const createEmptyConnectionProtectionConfig =
(): ConnectionProtectionConfig => ({ ...EMPTY_CONNECTION_PROTECTION });
export const deriveLegacyConnectionReadOnlyFlag = (
protection: unknown,
): boolean => {
const normalized = normalizeConnectionProtectionConfig(protection);
return CONNECTION_PROTECTION_KEYS.every((key) => normalized[key] === true);
};
export const resolveConnectionProtectionConfig = (
config: ConnectionReadOnlyLike,
): ConnectionProtectionConfig => {
if (!supportsConnectionReadOnlyMode(config)) {
return createEmptyConnectionProtectionConfig();
}
const normalized = normalizeConnectionProtectionConfig(config?.protection);
const hasExplicitRestriction = CONNECTION_PROTECTION_KEYS.some(
(key) => normalized[key] === true,
);
if (hasExplicitRestriction) {
return normalized;
}
if (config?.readOnly === true) {
return { ...FULL_CONNECTION_PROTECTION };
}
return createEmptyConnectionProtectionConfig();
};
export const isConnectionProtectionEnabled = (
config: ConnectionReadOnlyLike,
key: ConnectionProtectionKey,
): boolean => {
return resolveConnectionProtectionConfig(config)[key] === true;
};
export const getConnectionProtectionEnabledCount = (
config: ConnectionReadOnlyLike,
): number => {
const protection = resolveConnectionProtectionConfig(config);
return CONNECTION_PROTECTION_KEYS.filter(
(key) => protection[key] === true,
).length;
};
export const hasAnyConnectionProtection = (
config: ConnectionReadOnlyLike,
): boolean => {
return getConnectionProtectionEnabledCount(config) > 0;
};
export const isConnectionDataEditRestricted = (
config: ConnectionReadOnlyLike,
): boolean => {
return isConnectionProtectionEnabled(config, "restrictDataEdit");
};
export const isConnectionStructureEditRestricted = (
config: ConnectionReadOnlyLike,
): boolean => {
return isConnectionProtectionEnabled(config, "restrictStructureEdit");
};
export const isConnectionScriptExecutionRestricted = (
config: ConnectionReadOnlyLike,
): boolean => {
return isConnectionProtectionEnabled(config, "restrictScriptExecution");
};
export const isConnectionDataImportRestricted = (
config: ConnectionReadOnlyLike,
): boolean => {
return isConnectionProtectionEnabled(config, "restrictDataImport");
};
export const isConnectionForcedReadOnly = (
config: ConnectionReadOnlyLike,
): boolean => {
return deriveLegacyConnectionReadOnlyFlag(
resolveConnectionProtectionConfig(config),
);
};
export const findConnectionMutatingStatements = (
config: ConnectionReadOnlyLike,
sql: string,
): string[] => {
if (!isConnectionScriptExecutionRestricted(config)) {
return [];
}
return findSqlStatementRanges(String(sql || ""), resolveConnectionReadOnlyType(config))
.map((range) => range.text.trim())
.filter((statement) => statement.length > 0)
.filter((statement) => !isConnectionReadOnlyStatement(config, statement));
};
export const findPotentiallyMutatingConnectionStatements = (
config: ConnectionReadOnlyLike,
sql: string,
): string[] => {
const dbType = resolveConnectionReadOnlyType(config);
return findSqlStatementRanges(String(sql || ""), dbType)
.map((range) => range.text.trim())
.filter((statement) => statement.length > 0)
.filter((statement) => !isConnectionReadOnlyStatement(config, statement));
};