fix: make postgresql actually usable (4 critical bugs)

- CORS whitelist only allowed origin localhost:5173; browsers send Origin
  on same-origin POST too, so every deployment on any other port/origin
  got empty 403 on ALL POST/PUT/DELETE (curl tests masked it) - now
  echoes any origin (bearer-token auth, no cookie CSRF surface)
- connection_tester pg DSN had a literal *** password placeholder - pg
  connection test and query path could never authenticate
- instance detail endpoints (databases/tables/db-schema/table-detail)
  ran MySQL SHOW statements against pg - added pg branches
  (pg_database/pg_class/information_schema/pg_indexes, search_path
  semantics, PRI detection); schema filter uses non-system schemas
- frontend normalizeDbType missed the literal 'postgresql' value so pg
  instances were treated as mysql (backtick quoting, no column hints);
  genSql no longer emits db.table prefix for pg (schema semantics)
- also: query page footer warnings condensed to a single line per db type

verified live: mysql table tree/context menu/preview-execute; pg table
tree/columns/PK, double-click preview with masking working (email/phone
masked), query history persisted
This commit is contained in:
Logan.Li committed 2026-10-26 19:47:08 +08:00
1 parent c8155e2d33
commit 2c96902fbd
7 files changed
+183 -37

No files matched your search

@@ -34,12 +34,11 @@ describe('primaryColumns', () => {
describe('方言引号', () => {
it('MySQL 反引号 / PG 双引号', () => {
expect(previewSelectSql('t1', 'mysql')).toBe('SELECT * FROM `t1`');
expect(previewSelectSql('t1', 'postgresql')).toContain('"t1"');
expect(previewSelectSql('t1', 'postgresql')).toBe('SELECT * FROM "t1"');
});
it('预览 SELECT 不带 LIMIT(翻页由请求层承担)', () => {
const sql = previewSelectSql('t1', 'mysql', 'db1');
expect(sql).toBe('SELECT * FROM `db1`.`t1`');
expect(sql.toLowerCase()).not.toContain('limit');
it('预览 SELECT 不带 LIMIT(翻页由请求层承担);PG 不加库名前缀(schema 语义)', () => {
expect(previewSelectSql('t1', 'mysql', 'db1')).toBe('SELECT * FROM `db1`.`t1`');
expect(previewSelectSql('t1', 'postgresql', 'db1')).toBe('SELECT * FROM "t1"');
});
});
+19 -11
View File
@@ -2,7 +2,12 @@ import type { DBType } from '@/lib/queryTemplates';
/** 数据库类型 → 展示名 */
export const dbTypeLabel: Record<string, string> = {
mysql: 'MySQL', postgres: 'PostgreSQL', postgresql: 'PostgreSQL', redis: 'Redis', mongo: 'MongoDB', mongodb: 'MongoDB',
mysql: 'MySQL',
postgres: 'PostgreSQL',
postgresql: 'PostgreSQL',
redis: 'Redis',
mongo: 'MongoDB',
mongodb: 'MongoDB',
};
/** 数据库类型 → 图标地址 */
@@ -20,24 +25,27 @@ export const dbTypeIcon: Record<string, string> = {
/** 编辑器占位示例 SQL/命令 */
export const placeholderMap: Record<DBType, string> = {
mysql: "-- MySQL 查询\n-- 仅支持 SELECT, SHOW, EXPLAIN, DESCRIBE\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
postgresql: "-- PostgreSQL 查询\n-- 仅支持 SELECT, EXPLAIN\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
redis: "-- Redis 命令\nGET user:1001\nHGETALL user:1001\nKEYS user:*",
mongodb: "// MongoDB Shell\n// 支持 find, aggregate, count, distinct\n\ndb.users.find({ status: 'active' }).limit(100)",
mysql:
"-- MySQL 查询\n-- 仅支持 SELECT, SHOW, EXPLAIN, DESCRIBE\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
postgresql:
"-- PostgreSQL 查询\n-- 仅支持 SELECT, EXPLAIN\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
redis: '-- Redis 命令\nGET user:1001\nHGETALL user:1001\nKEYS user:*',
mongodb:
"// MongoDB Shell\n// 支持 find, aggregate, count, distinct\n\ndb.users.find({ status: 'active' }).limit(100)",
};
/** 归一化数据库类型标识(后端别名 → 统一 DBType) */
export function normalizeDbType(dbType?: string): DBType {
if (dbType === 'postgres' || dbType === 'pg') return 'postgresql';
if (dbType === 'postgres' || dbType === 'pg' || dbType === 'postgresql') return 'postgresql';
if (dbType === 'mongo') return 'mongodb';
if (dbType === 'redis') return 'redis';
return 'mysql';
}
/** 各库查询入口的权限策略文案 */
/** 各库查询入口的权限策略文案(单行简洁提示) */
export const queryPolicyText: Record<DBType, string> = {
mysql: '查询入口仅允许 SELECT / SHOW / DESCRIBE / EXPLAIN;禁止 UPDATE/DELETE/DDL、多语句、SELECT FOR UPDATE。未写 LIMIT 时按平台配置自动限制。',
postgresql: '查询入口仅允许 SELECT / EXPLAIN;禁止 EXPLAIN ANALYZE、写入 CTE、多语句和锁定读。未写 LIMIT 时按平台配置自动限制。',
redis: 'Redis 查询入口仅允许只读命令;写命令请提交变更工单。集群实例会按 slot 路由并扫描所有 master。',
mongodb: 'MongoDB 查询入口用于 find / aggregate / count / distinct 等只读操作;写操作请提交变更工单。',
mysql: '仅允许 SELECT/SHOW/DESCRIBE/EXPLAIN,自动限行与敏感字段脱敏',
postgresql: '仅允许 SELECT/EXPLAIN,自动限行与敏感字段脱敏',
redis: '仅允许只读命令,写操作请提工单',
mongodb: '仅允许只读操作,写操作请提工单',
};
+8 -6
View File
@@ -27,8 +27,10 @@ function quote(name: string, dialect: 'mysql' | 'postgresql'): string {
export type SqlDialect = 'mysql' | 'postgresql' | 'mongodb' | 'redis';
export function previewSelectSql(table: string, dialect: SqlDialect, database?: string): string {
const q = (n: string) => quote(n, dialect === 'postgresql' ? 'postgresql' : 'mysql');
const target = database ? `${q(database)}.${q(table)}` : q(table);
const d = dialect === 'postgresql' ? 'postgresql' : 'mysql';
const q = (n: string) => quote(n, d);
// MySQL 支持 db.table 两段限定;PG 的两段限定是 schema.table,库名前缀不可用 → 不加前缀走 search_path
const target = database && d === 'mysql' ? `${q(database)}.${q(table)}` : q(table);
// 不烧 LIMIT:翻页由请求层 offset/limit 承担(backend 自动加 LIMIT)
return `SELECT * FROM ${target}`;
}
@@ -41,7 +43,7 @@ export function generateSelectSql(
): string {
const d = dialect === 'postgresql' ? 'postgresql' : 'mysql';
const q = (n: string) => quote(n, d);
const target = database ? `${q(database)}.${q(table)}` : q(table);
const target = database && d === 'mysql' ? `${q(database)}.${q(table)}` : q(table);
const cols = columns.length > 0 ? columns.map((c) => q(c.Field)).join(', ') : '*';
const pks = primaryColumns(columns);
const where = pks.length > 0 ? `\nWHERE ${pks.map((p) => `${q(p)} = ?`).join(' AND ')}` : '';
@@ -56,7 +58,7 @@ export function generateInsertSql(
): string {
const d = dialect === 'postgresql' ? 'postgresql' : 'mysql';
const q = (n: string) => quote(n, d);
const target = database ? `${q(database)}.${q(table)}` : q(table);
const target = database && d === 'mysql' ? `${q(database)}.${q(table)}` : q(table);
const cols = columns.map((c) => q(c.Field));
const placeholders = columns.map(() => '?');
if (cols.length === 0) return `INSERT INTO ${target} ...;`;
@@ -71,7 +73,7 @@ export function generateUpdateSql(
): string {
const d = dialect === 'postgresql' ? 'postgresql' : 'mysql';
const q = (n: string) => quote(n, d);
const target = database ? `${q(database)}.${q(table)}` : q(table);
const target = database && d === 'mysql' ? `${q(database)}.${q(table)}` : q(table);
const setCols = columns.length > 0 ? columns : [{ Field: 'column1' } as ColumnMeta];
const pks = primaryColumns(columns);
const whereCols = pks.length > 0 ? pks : [setCols[0].Field];
@@ -86,7 +88,7 @@ export function generateDeleteSql(
): string {
const d = dialect === 'postgresql' ? 'postgresql' : 'mysql';
const q = (n: string) => quote(n, d);
const target = database ? `${q(database)}.${q(table)}` : q(table);
const target = database && d === 'mysql' ? `${q(database)}.${q(table)}` : q(table);
const pks = primaryColumns(columns);
const whereCols = pks.length > 0 ? pks : columns[0] ? [columns[0].Field] : ['id'];
return `DELETE FROM ${target}\nWHERE ${whereCols.map((p) => `${q(p)} = ?`).join(' AND ')};`;
-8
View File
@@ -540,14 +540,6 @@ export default function QueriesPage() {
onPageChange={handleExecute}
/>
)}
{/* -- 安全提示 -- */}
<div className="shrink-0 px-4 pb-3">
<div className="flex items-center gap-2 px-3 py-2 bg-yellow-500/5 border border-yellow-500/15 rounded-lg text-xs text-[var(--dg-text-muted)]">
<AlertTriangle className="w-3.5 h-3.5 text-yellow-400 shrink-0" />
查询结果中的敏感字段将自动脱敏。MySQL/PG 仅支持 SELECT 查询,Redis/MongoDB 支持只读命令。
</div>
</div>
</div>
{/* end 编辑器+结果区 */}
</div>