mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-17 19:02:07 +08:00
- 新增数据编辑、结构编辑、脚本执行和数据导入四类连接级保护配置 - 升级生产连接保护弹窗为多选卡片,并修复选项对齐与勾选态显示 - 按保护类型收口 QueryEditor、DataGrid、表设计、导入与同步目标入口 - 后端统一拦截 SQL 或 Mongo 写操作、结果编辑、结构变更和导入写入 - AI 本地工具与 RPC 执行链路透传连接保护配置并复用后端守卫 - 补充多语言文案、定向测试与需求追踪记录
374 lines
9.5 KiB
TypeScript
374 lines
9.5 KiB
TypeScript
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",
|
|
]);
|
|
|
|
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 stripLeadingSqlComments = (statement: string): string => {
|
|
let text = String(statement || "").trim();
|
|
while (text) {
|
|
if (text.startsWith("--")) {
|
|
const next = text.indexOf("\n");
|
|
text = next >= 0 ? text.slice(next + 1).trimStart() : "";
|
|
continue;
|
|
}
|
|
if (text.startsWith("#")) {
|
|
const next = text.indexOf("\n");
|
|
text = next >= 0 ? text.slice(next + 1).trimStart() : "";
|
|
continue;
|
|
}
|
|
if (text.startsWith("/*")) {
|
|
const next = text.indexOf("*/");
|
|
text = next >= 0 ? text.slice(next + 2).trimStart() : "";
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
return text;
|
|
};
|
|
|
|
const extractLeadingSqlKeyword = (statement: string): string => {
|
|
const text = stripLeadingSqlComments(statement);
|
|
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): boolean => {
|
|
const text = stripLeadingSqlComments(statement);
|
|
if (!text) return true;
|
|
const keyword = extractLeadingSqlKeyword(text);
|
|
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);
|
|
};
|
|
|
|
export const supportsConnectionReadOnlyMode = (
|
|
config: ConnectionReadOnlyLike,
|
|
): boolean => {
|
|
return CONNECTION_READ_ONLY_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 || ""))
|
|
.map((range) => range.text.trim())
|
|
.filter((statement) => statement.length > 0)
|
|
.filter((statement) => !isConnectionReadOnlyStatement(config, statement));
|
|
};
|