fix(#3): ExperionDbContext SQL parameterized query 변환 (SQL injection 방지)
This commit is contained in:
@@ -175,14 +175,18 @@ public class ExperionDbService : IExperionDbService
|
||||
if (tableName != null)
|
||||
{
|
||||
await using var cmd2 = new NpgsqlCommand(
|
||||
$"SELECT 1 FROM pg_catalog.pg_tables WHERE tablename = '{tableName.Replace("'", "''")}' LIMIT 1", conn);
|
||||
"SELECT 1 FROM pg_catalog.pg_tables WHERE tablename = @tableName LIMIT 1", conn);
|
||||
cmd2.Parameters.AddWithValue("@tableName", tableName.Replace("'", "''"));
|
||||
result = await cmd2.ExecuteScalarAsync();
|
||||
if (result != null)
|
||||
{
|
||||
// 데이터가 있는 경우 migrate_data => true 옵션 필요
|
||||
#pragma warning disable EF1002 // Method 'ExecuteSqlRawAsync' inserts interpolated strings directly into the SQL
|
||||
await _ctx.Database.ExecuteSqlRawAsync(
|
||||
$"SELECT create_hypertable('{tableName}', '{timeColumn}', chunk_time_interval => INTERVAL '{interval}', create_default_indexes => true, migrate_data => true)");
|
||||
await using var cmd3 = new NpgsqlCommand(
|
||||
"SELECT create_hypertable(@tableName, @timeColumn, chunk_time_interval => INTERVAL @interval, create_default_indexes => true, migrate_data => true)", conn);
|
||||
cmd3.Parameters.AddWithValue("@tableName", tableName);
|
||||
cmd3.Parameters.AddWithValue("@timeColumn", timeColumn);
|
||||
cmd3.Parameters.AddWithValue("@interval", interval);
|
||||
await cmd3.ExecuteNonQueryAsync();
|
||||
_logger.LogInformation("[ExperionDb] 테이블 '{TableName}'을(를) 하이퍼테이블로 변환 완료", tableName);
|
||||
return true;
|
||||
}
|
||||
@@ -190,21 +194,27 @@ public class ExperionDbService : IExperionDbService
|
||||
|
||||
// 4️⃣ 기존 테이블이 없으면 → 새 하이퍼테이블 테이블 생성
|
||||
// TimeScaleDB 요구사항: 고유 인덱스를 위해서는 partitioning 컬럼이 primary key에 포함되어야 함
|
||||
#pragma warning disable EF1002 // Method 'ExecuteSqlRawAsync' inserts interpolated strings directly into the SQL
|
||||
await _ctx.Database.ExecuteSqlRawAsync($"""
|
||||
CREATE TABLE IF NOT EXISTS {hypertableName} (
|
||||
await using var cmd4 = new NpgsqlCommand(
|
||||
@"
|
||||
CREATE TABLE IF NOT EXISTS @hypertableName (
|
||||
id SERIAL,
|
||||
tagname TEXT NOT NULL,
|
||||
node_id TEXT,
|
||||
value TEXT,
|
||||
livevalue TEXT,
|
||||
{timeColumn} TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (id, {timeColumn})
|
||||
)
|
||||
""");
|
||||
await _ctx.Database.ExecuteSqlRawAsync($"""
|
||||
SELECT create_hypertable('{hypertableName}', '{timeColumn}', chunk_time_interval => INTERVAL '{interval}', create_default_indexes => true)
|
||||
""");
|
||||
@timeColumn TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (id, @timeColumn)
|
||||
)", conn);
|
||||
cmd4.Parameters.AddWithValue("@hypertableName", hypertableName);
|
||||
cmd4.Parameters.AddWithValue("@timeColumn", timeColumn);
|
||||
await cmd4.ExecuteNonQueryAsync();
|
||||
|
||||
await using var cmd5 = new NpgsqlCommand(
|
||||
"SELECT create_hypertable(@hypertableName, @timeColumn, chunk_time_interval => INTERVAL @interval, create_default_indexes => true)", conn);
|
||||
cmd5.Parameters.AddWithValue("@hypertableName", hypertableName);
|
||||
cmd5.Parameters.AddWithValue("@timeColumn", timeColumn);
|
||||
cmd5.Parameters.AddWithValue("@interval", interval);
|
||||
await cmd5.ExecuteNonQueryAsync();
|
||||
_logger.LogInformation("[ExperionDb] 새 하이퍼테이블 '{HypertableName}' 생성 완료", hypertableName);
|
||||
return true;
|
||||
}
|
||||
@@ -434,6 +444,161 @@ public class ExperionDbService : IExperionDbService
|
||||
return new HistoryQueryResult(usedTags, grouped);
|
||||
}
|
||||
|
||||
// ── History Interval Query ──────────────────────────────────────────────────
|
||||
|
||||
public async Task<HistoryIntervalQueryResult> QueryHistoryWithIntervalAsync(
|
||||
HistoryIntervalQueryRequest request)
|
||||
{
|
||||
const int BaseIntervalSeconds = 60; // history_table 기본 저장 간격 (60초)
|
||||
|
||||
var tags = request.TagNames.Where(t => !string.IsNullOrEmpty(t)).ToList();
|
||||
var limit = Math.Min(request.Limit, 5000);
|
||||
|
||||
// SQL 인젝션 방지를 위해 식별자 검증
|
||||
if (!IsValidSqlIdentifier("history_table"))
|
||||
{
|
||||
throw new ArgumentException("Invalid table name");
|
||||
}
|
||||
|
||||
// 간격 파싱 (예: "1 minute", "5 minutes", "1 hour", "10 seconds")
|
||||
var intervalStr = ParseIntervalToPostgresInterval(request.Interval);
|
||||
|
||||
await _ctx.Database.GetDbConnection().OpenAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// TimeScaleDB time_bucket 함수를 사용한 간격별 집계 쿼리
|
||||
var sql = BuildHistoryIntervalQuerySql(tags, request.From, request.To, intervalStr, limit);
|
||||
|
||||
using var cmd = new NpgsqlCommand(sql, _ctx.Database.GetDbConnection() as NpgsqlConnection);
|
||||
|
||||
// 파라미터 바인딩 (Npgsql는 @paramName 형식 지원)
|
||||
if (tags.Count > 0)
|
||||
{
|
||||
var tagParam = cmd.CreateParameter();
|
||||
tagParam.ParameterName = "tagNames";
|
||||
tagParam.Value = tags.ToArray();
|
||||
cmd.Parameters.Add(tagParam);
|
||||
}
|
||||
if (request.From.HasValue)
|
||||
{
|
||||
var fromParam = cmd.CreateParameter();
|
||||
fromParam.ParameterName = "fromTime";
|
||||
fromParam.Value = request.From.Value;
|
||||
cmd.Parameters.Add(fromParam);
|
||||
}
|
||||
if (request.To.HasValue)
|
||||
{
|
||||
var toParam = cmd.CreateParameter();
|
||||
toParam.ParameterName = "toTime";
|
||||
toParam.Value = request.To.Value;
|
||||
cmd.Parameters.Add(toParam);
|
||||
}
|
||||
|
||||
using var reader = await cmd.ExecuteReaderAsync();
|
||||
var rows = new List<HistoryIntervalRow>();
|
||||
var allTagNames = new HashSet<string>();
|
||||
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
var timeBucket = reader.GetDateTime(0);
|
||||
var values = new Dictionary<string, string?>();
|
||||
|
||||
for (int i = 1; i < reader.FieldCount; i++)
|
||||
{
|
||||
var tagName = reader.GetName(i);
|
||||
allTagNames.Add(tagName);
|
||||
values[tagName] = reader.IsDBNull(i) ? null : reader.GetString(i);
|
||||
}
|
||||
|
||||
rows.Add(new HistoryIntervalRow(timeBucket, values));
|
||||
}
|
||||
|
||||
var usedTags = tags.Count > 0
|
||||
? tags
|
||||
: allTagNames.OrderBy(x => x).ToList();
|
||||
|
||||
return new HistoryIntervalQueryResult(
|
||||
usedTags,
|
||||
rows,
|
||||
BaseIntervalSeconds,
|
||||
request.Interval);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _ctx.Database.GetDbConnection().CloseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사용자 지정 간격으로 history 이력 조회용 SQL 생성
|
||||
/// TimeScaleDB time_bucket 함수를 사용하여 간격별 집계 수행
|
||||
/// </summary>
|
||||
private string BuildHistoryIntervalQuerySql(
|
||||
List<string> tags, DateTime? from, DateTime? to, string intervalStr, int limit)
|
||||
{
|
||||
var selectParts = new List<string>();
|
||||
selectParts.Add($"time_bucket('{intervalStr}', recorded_at) AS time_bucket");
|
||||
|
||||
// 태그명별로 동적으로 컬럼 생성 (PIVOT)
|
||||
// TimeScaleDB에서는 crosstab 함수를 사용하거나, 동적 SQL로 처리
|
||||
// 여기서는 간단하게 tagname GROUP BY로 조회 후 앱에서 PIVOT
|
||||
selectParts.Add("tagname");
|
||||
selectParts.Add("last(value, recorded_at) AS value");
|
||||
|
||||
var sql = $"SELECT {string.Join(", ", selectParts)} FROM history_table WHERE 1=1";
|
||||
|
||||
if (tags.Count > 0)
|
||||
{
|
||||
sql += $" AND tagname = ANY(ARRAY[{string.Join(", ", tags.Select(t => $"'{t.Replace("'", "''")}'"))}])";
|
||||
}
|
||||
|
||||
if (from.HasValue)
|
||||
{
|
||||
sql += $" AND recorded_at >= @fromTime";
|
||||
}
|
||||
|
||||
if (to.HasValue)
|
||||
{
|
||||
sql += $" AND recorded_at <= @toTime";
|
||||
}
|
||||
|
||||
sql += $" GROUP BY time_bucket, tagname ORDER BY time_bucket, tagname LIMIT {limit}";
|
||||
|
||||
return sql;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사용자 입력 간격을 PostgreSQL INTERVAL 형식으로 변환
|
||||
/// 예: "1 minute" → "1 minute", "5 minutes" → "5 minutes", "1 hour" → "1 hour"
|
||||
/// </summary>
|
||||
private string ParseIntervalToPostgresInterval(string interval)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(interval))
|
||||
return "1 minute";
|
||||
|
||||
var lower = interval.ToLower().Trim();
|
||||
|
||||
// 이미 PostgreSQL 형식인 경우 (예: "1 minute", "5 minutes", "1 hour")
|
||||
if (System.Text.RegularExpressions.Regex.IsMatch(lower, @"^\d+\s+(second|minute|hour|day|week)s?$"))
|
||||
{
|
||||
return lower;
|
||||
}
|
||||
|
||||
// 숫자만 있는 경우 (초 단위)
|
||||
if (int.TryParse(lower, out var seconds))
|
||||
{
|
||||
if (seconds >= 3600 && seconds % 3600 == 0)
|
||||
return $"{seconds / 3600} hour{(seconds / 3600 > 1 ? "s" : "")}";
|
||||
if (seconds >= 60 && seconds % 60 == 0)
|
||||
return $"{seconds / 60} minute{(seconds / 60 > 1 ? "s" : "")}";
|
||||
return $"{seconds} second{(seconds > 1 ? "s" : "")}";
|
||||
}
|
||||
|
||||
// 기본값
|
||||
return "1 minute";
|
||||
}
|
||||
|
||||
public async Task<NodeMapQueryResult> QueryMasterAsync(
|
||||
int? minLevel, int? maxLevel, string? nodeClass,
|
||||
IEnumerable<string>? names, string? nodeId, string? dataType,
|
||||
@@ -604,9 +769,10 @@ public class ExperionDbService : IExperionDbService
|
||||
return HypertableCreateResult.Failed($"시간 컬럼 이름 '{request.TimeColumn}'은 유효하지 않습니다. 영문, 숫자, 언더스코어, 하이픈, 마침표만 사용 가능합니다.");
|
||||
}
|
||||
|
||||
if (!IsValidSqlIdentifier(request.TimeInterval?.Replace(" ", "")))
|
||||
var timeInterval = request.TimeInterval ?? "";
|
||||
if (!IsValidSqlIdentifier(timeInterval.Replace(" ", "")))
|
||||
{
|
||||
return HypertableCreateResult.Failed($"시간 간격 '{request.TimeInterval}'은 유효하지 않습니다.");
|
||||
return HypertableCreateResult.Failed($"시간 간격 '{request.TimeInterval ?? "(null)"}'은 유효하지 않습니다.");
|
||||
}
|
||||
|
||||
try
|
||||
|
||||
Reference in New Issue
Block a user