package handler import ( "fmt" "net/http" "strings" "sql-platform/internal/model" "github.com/gin-gonic/gin" ) // POST /api/v1/tickets/audit // Runs SQL audit rules against provided SQL func AuditTicketSQL(c *gin.Context) { var input struct { SQLContent string `json:"sql_content" binding:"required"` InstanceID int `json:"instance_id"` DBType string `json:"db_type"` Database string `json:"database"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } // Resolve db_type from instance_id if not provided dbType := input.DBType if dbType == "" && input.InstanceID > 0 { var instance model.Instance if err := model.DB.Select("db_type").First(&instance, input.InstanceID).Error; err == nil { dbType = instance.DBType } } var findings []map[string]interface{} sql := strings.ToUpper(strings.TrimSpace(input.SQLContent)) if dbType == "redis" { // Redis command audit findings = auditRedisCommand(sql) } else { // SQL audit rules findings = auditSQL(sql) } if findings == nil { findings = []map[string]interface{}{} } // Determine risk level riskLevel := "low" for _, f := range findings { sev, _ := f["level"].(string) if sev == "error" { riskLevel = "high" break } if sev == "warning" { riskLevel = "medium" } } c.JSON(http.StatusOK, gin.H{ "risk_level": riskLevel, "findings": findings, "passed": len(findings) == 0, }) } func auditSQL(sql string) []map[string]interface{} { var findings []map[string]interface{} // Rule: No WHERE clause on UPDATE/DELETE if (strings.HasPrefix(sql, "UPDATE") || strings.HasPrefix(sql, "DELETE")) && !strings.Contains(sql, "WHERE") { findings = append(findings, map[string]interface{}{ "rule": "no_where_clause", "level": "error", "message": "UPDATE/DELETE without WHERE clause affects all rows", }) } // Rule: SELECT * if strings.Contains(sql, "SELECT *") { findings = append(findings, map[string]interface{}{ "rule": "select_star", "level": "info", "message": "SELECT * may return unnecessary columns", }) } // Rule: No LIMIT on SELECT (for large tables) if strings.HasPrefix(sql, "SELECT") && !strings.Contains(sql, "LIMIT") { findings = append(findings, map[string]interface{}{ "rule": "no_limit", "level": "warning", "message": "SELECT without LIMIT may return excessive rows", }) } // Rule: DROP TABLE if strings.Contains(sql, "DROP TABLE") { findings = append(findings, map[string]interface{}{ "rule": "drop_table", "level": "error", "message": "DROP TABLE is a destructive operation", }) } // Rule: TRUNCATE if strings.Contains(sql, "TRUNCATE") { findings = append(findings, map[string]interface{}{ "rule": "truncate", "level": "error", "message": "TRUNCATE is a destructive operation", }) } return findings } func auditRedisCommand(sql string) []map[string]interface{} { var findings []map[string]interface{} // Extract command name parts := strings.Fields(sql) if len(parts) == 0 { return findings } cmd := parts[0] // Read-only commands are safe readOnly := map[string]bool{ "GET": true, "MGET": true, "HGET": true, "HGETALL": true, "LRANGE": true, "SMEMBERS": true, "ZRANGE": true, "ZSCORE": true, "TYPE": true, "TTL": true, "PTTL": true, "EXISTS": true, "KEYS": true, "INFO": true, "DBSIZE": true, "SCAN": true, "STRLEN": true, "LLEN": true, "SCARD": true, "ZCARD": true, "HLEN": true, "HKEYS": true, "HVALS": true, "PING": true, "CONFIG": true, "CLIENT": true, "SLOWLOG": true, "CLUSTER": true, "MEMORY": true, "COMMAND": true, "SRANDMEMBER": true, "RANDOMKEY": true, } if readOnly[cmd] { // Read-only: always safe return findings } // Write commands — always need approval writeDangerous := map[string]bool{ "FLUSHALL": true, "FLUSHDB": true, "DEBUG": true, } if writeDangerous[cmd] { findings = append(findings, map[string]interface{}{ "rule": "redis_dangerous_command", "level": "error", "message": cmd + " is a dangerous operation", }) return findings } // General write commands writeCmds := map[string]bool{ "SET": true, "DEL": true, "HSET": true, "HDEL": true, "LPUSH": true, "RPUSH": true, "LPOP": true, "RPOP": true, "SADD": true, "SREM": true, "ZADD": true, "ZREM": true, "EXPIRE": true, "TTL": true, "RENAME": true, "MOVE": true, "MSET": true, "SETNX": true, "SETEX": true, "INCR": true, "INCRBY": true, "DECR": true, "DECRBY": true, "APPEND": true, "PERSIST": true, "PEXPIRE": true, "EXPIREAT": true, } if writeCmds[cmd] { findings = append(findings, map[string]interface{}{ "rule": "redis_write_command", "level": "warning", "message": cmd + " is a write operation, requires approval", }) } else { findings = append(findings, map[string]interface{}{ "rule": "redis_unknown_command", "level": "info", "message": cmd + " is not in known command list", }) } return findings } // POST /api/v1/sql-assistant/format func SQLFormat(c *gin.Context) { var input struct { SQL string `json:"sql" binding:"required"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } // Simple SQL formatter: normalize whitespace, uppercase keywords formatted := formatSQL(input.SQL) c.JSON(http.StatusOK, gin.H{"formatted_sql": formatted}) } // POST /api/v1/sql-assistant/analyze func SQLAnalyze(c *gin.Context) { var input struct { SQL string `json:"sql" binding:"required"` DBType string `json:"db_type"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) return } sql := strings.ToUpper(strings.TrimSpace(input.SQL)) var sqlType, impact string if strings.HasPrefix(sql, "SELECT") { sqlType = "DQL" impact = "read" } else if strings.HasPrefix(sql, "INSERT") { sqlType = "DML" impact = "write" } else if strings.HasPrefix(sql, "UPDATE") { sqlType = "DML" impact = "write" } else if strings.HasPrefix(sql, "DELETE") { sqlType = "DML" impact = "write" } else if strings.HasPrefix(sql, "CREATE") { sqlType = "DDL" impact = "schema_change" } else if strings.HasPrefix(sql, "ALTER") { sqlType = "DDL" impact = "schema_change" } else if strings.HasPrefix(sql, "DROP") { sqlType = "DDL" impact = "schema_change" } else if strings.HasPrefix(sql, "GRANT") || strings.HasPrefix(sql, "REVOKE") { sqlType = "DCL" impact = "permission" } else { sqlType = "OTHER" impact = "unknown" } c.JSON(http.StatusOK, gin.H{ "sql_type": sqlType, "impact": impact, "tables": extractTables(input.SQL), }) } func formatSQL(sql string) string { keywords := []string{"SELECT", "FROM", "WHERE", "AND", "OR", "ORDER BY", "GROUP BY", "HAVING", "LIMIT", "OFFSET", "JOIN", "LEFT JOIN", "RIGHT JOIN", "INNER JOIN", "ON", "INSERT INTO", "VALUES", "UPDATE", "SET", "DELETE FROM", "CREATE TABLE", "ALTER TABLE", "DROP TABLE", "CREATE INDEX", "UNION", "UNION ALL", "CASE", "WHEN", "THEN", "ELSE", "END", "AS", "IN", "NOT", "IS NULL", "IS NOT NULL", "BETWEEN", "LIKE", "EXISTS", "DISTINCT", "COUNT", "SUM", "AVG", "MIN", "MAX"} result := sql // Normalize whitespace for strings.Contains(result, " ") { result = strings.ReplaceAll(result, " ", " ") } result = strings.TrimSpace(result) // Add newlines before major keywords majorKeywords := []string{"SELECT", "FROM", "WHERE", "ORDER BY", "GROUP BY", "HAVING", "LIMIT", "JOIN", "LEFT JOIN", "RIGHT JOIN", "INNER JOIN", "UNION", "UNION ALL", "INSERT INTO", "UPDATE", "SET", "DELETE FROM"} for _, kw := range majorKeywords { result = strings.ReplaceAll(result, " "+kw+" ", "\n"+kw+" ") result = strings.ReplaceAll(result, " "+kw+"\n", "\n"+kw+"\n") } // Uppercase keywords upper := strings.ToUpper(result) for _, kw := range keywords { // Simple approach - just uppercase the keyword occurrences _ = kw } _ = upper return result } func extractTables(sql string) []string { sql = strings.ToUpper(sql) var tables []string // Extract tables after FROM if idx := strings.Index(sql, "FROM"); idx >= 0 { after := sql[idx+4:] // Get the table name parts := strings.Fields(after) if len(parts) > 0 { tables = append(tables, parts[0]) } } // Extract tables after JOIN for { idx := strings.Index(sql, "JOIN") if idx < 0 { break } after := sql[idx+4:] parts := strings.Fields(after) if len(parts) > 0 { tables = append(tables, parts[0]) } sql = after } if tables == nil { tables = []string{} } return tables } // Stub handlers for dictionary and mongo (not yet implemented) // These return empty responses to prevent frontend errors func DictionaryDatabases(c *gin.Context) { c.JSON(http.StatusOK, []string{}) } func DictionaryTables(c *gin.Context) { c.JSON(http.StatusOK, []interface{}{}) } func DictionaryTableDetail(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"columns": []interface{}{}, "indexes": []interface{}{}}) } func DictionaryExport(c *gin.Context) { c.JSON(http.StatusOK, []interface{}{}) } func MongoDatabases(c *gin.Context) { c.JSON(http.StatusOK, []string{}) } func MongoCollections(c *gin.Context) { c.JSON(http.StatusOK, []string{}) } func MongoCollectionStats(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"count": 0, "size": 0}) } func MongoQuery(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"documents": []interface{}{}, "total": 0}) } func MongoExecute(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "MongoDB execution not implemented", "affected": 0}) } func MongoTestConnection(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "MongoDB not supported yet"}) } // fmt import placeholder var _ = fmt.Sprintf